From 8c55daffb29220adce6bff5ceb3e9e3d91f860f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Sun, 26 Jul 2026 15:44:29 -0300 Subject: [PATCH 1/2] =?UTF-8?q?B33:=20ordena=C3=A7=C3=A3o=20e=20filtro=20n?= =?UTF-8?q?o=20header=20da=20lista=20de=20dobradinhas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - stateDeputyListUrl.ts / stateDeputyListFilters.ts: contrato de URL/filtro próprio (sort/dir, filtro de Partido multi + sentinela "Sem partido"), espelhando municipalityListUrl.ts (paginação/resolveListUrl) e territoryListUrl.ts (sort keys/labels) - stateDeputyData.ts: where/sort resolvidos no banco + facet de partido (loadStateDeputyPartyFacet), select estreito na query principal - StateDeputySortableHead / StateDeputyHeaderFilter / StateDeputyFilters: wrappers finos sobre o chrome compartilhado (shared/CampaignSortableHead, CampaignHeaderFilterPopover), paridade mobile incluída - dobradinhas/page.tsx: redirect canônico em duas fases, CampaignTable sempre montada com caption + empty state, resumo de ordenação aria-live - Correção pós-entrega (design review): "Sem partido" no mobile + caption da tabela + copy plural do empty state - /simplify (1ª rodada): buildStateDeputyPartyOptions compartilhado, remove discriminante filterParam redundante, dedupe/if mortos em stateDeputyListUrl.ts - /simplify (2ª rodada, pós-rebase): remove cast/type morto em stateDeputyData.ts, stateDeputyColumns usa StateDeputyFilterOption em vez de duplicar a forma, inlinea parsePartiesParam; extrai truncatedNamesLabel compartilhado em campaignListUrl.ts (3º call site — municipalityListFilters e territoryListFilters tinham a mesma função com nomes distintos) Gate verde (tsc, lint, format, check:cycles, 396 testes, build); Aikido 0 achados nos 7 arquivos tocados nesta rodada. Docs: docs/plans/ordenacao-filtros-lista-dobradinhas.md, docs/roadmap.md, .cursor/rules/projects/nucleos-eleitorais.mdc. --- .cursor/rules/projects/nucleos-eleitorais.mdc | 1 + .../ordenacao-filtros-lista-dobradinhas.md | 22 +- docs/roadmap.md | 8 +- .../campanha/(app)/dobradinhas/page.tsx | 147 ++++++++--- .../stateDeputy/StateDeputyFilters.tsx | 174 +++++++++++++ .../stateDeputy/StateDeputyHeaderFilter.tsx | 64 +++++ .../stateDeputy/StateDeputySortableHead.tsx | 65 +++++ src/utilities/campaignListUrl.ts | 4 + src/utilities/municipalityListFilters.ts | 11 +- src/utilities/stateDeputyData.ts | 108 +++++--- src/utilities/stateDeputyListFilters.ts | 85 +++++++ src/utilities/stateDeputyListUrl.ts | 231 ++++++++++++++++++ src/utilities/territoryListFilters.ts | 6 +- tests/int/campaignEntityListData.int.spec.ts | 80 ++++++ .../campaignEntityListParsers.unit.spec.ts | 11 +- tests/unit/stateDeputyListUrl.unit.spec.ts | 152 ++++++++++++ 16 files changed, 1068 insertions(+), 101 deletions(-) create mode 100644 src/components/campaign/stateDeputy/StateDeputyFilters.tsx create mode 100644 src/components/campaign/stateDeputy/StateDeputyHeaderFilter.tsx create mode 100644 src/components/campaign/stateDeputy/StateDeputySortableHead.tsx create mode 100644 src/utilities/stateDeputyListFilters.ts create mode 100644 src/utilities/stateDeputyListUrl.ts create mode 100644 tests/unit/stateDeputyListUrl.unit.spec.ts diff --git a/.cursor/rules/projects/nucleos-eleitorais.mdc b/.cursor/rules/projects/nucleos-eleitorais.mdc index 9e94651f..85dc8a85 100644 --- a/.cursor/rules/projects/nucleos-eleitorais.mdc +++ b/.cursor/rules/projects/nucleos-eleitorais.mdc @@ -17,6 +17,7 @@ alwaysApply: false **UI.** Legenda discreta (`MapScaleLegend`) com as faixas reais, do mais fraco para o mais forte, e a nota ligada ao seletor por `aria-describedby`; o readout **nunca** mostra a classe sozinha (research §6.4), soletra o porquê em texto real ("4ª de 5 faixas", "1,8× o padrão estadual do candidato", "12º entre 663 candidatos votados aqui"). Bolhas opcionais por válidos projetados (área ∝ valor, `interactive: false` para não empilhar dois alvos de hover no mesmo município, desenhadas da menor para a maior); **medido antes de otimizar** — 417 marcadores não derrubaram o frame, então o corte para top-K não foi feito. Dois conceitos novos em `/campanha/conceitos` (E18) com "Saiba mais" inline na nota; LQ já estava coberto por `dominancia-relativa`. Sem migration, sem collection, sem server action; o **First Load JS** de `/campanha` ficou byte a byte igual (9,46 kB / 265 kB) porque o painel do mapa já carrega em chunk próprio (`ssr: false`) — o chunk em si cresceu, e o payload RSC subiu ~64 KB não comprimidos (~10 KB gzip), quase tudo `competitiveRankByYear`. Gate verde (tsc, lint, format, knip, check:cycles, 467 unit / 387 int, build); Aikido sem achado. **Débito de teste registrado:** `campaignMunicipalities.e2e.spec.ts` é flaky com 2 workers **em `main` também** — verificado por stash: com as mudanças passa 5/5 isolado e falha na suíte cheia; sem as mudanças passou 15/15 numa rodada e falhou na seguinte. Duas causas visíveis: o checkbox de consentimento do `LeaderContactsPanel` clicado antes da hidratação ("Clicking the checkbox did not change its state") e colisão de regex quando o fixture sorteia "Conde" e o `getByRole` casa também "Condeúba". +- **B33 entregue em código (2026-07-26):** ordenação e filtro no header de `/campanha/dobradinhas` — `stateDeputyListUrl.ts`/`stateDeputyListFilters.ts` (contrato de URL/filtro próprio, sentinela `NO_PARTY_FILTER_VALUE` para "Sem partido") espelhando `municipalityListUrl.ts` (paginação/`resolveListUrl`) e `territoryListUrl.ts` (chaves/labels de sort) — não `leadershipListUrl.ts`, que o plano mandava espelhar mas **não existe** (B29 não entregou). `StateDeputySortableHead`/`StateDeputyHeaderFilter` consomem o chrome já compartilhado pelo **B21 ✓** sem extrair de novo (a corrida de ordem de chegada com B29 está encerrada); `loadStateDeputyPartyFacet` segue o contrato de faceta de `loadMunicipalityListFilterFacets` (respeita a busca, ignora o próprio filtro do popover). Ordem dos nulos de `party` **pinada por int test** em vez de assumida — Postgres/Payload ordena nulls por último em `asc` e primeiro em `desc`; "Sem partido" continua sendo o caminho direto via `party: { exists: false }`. Paridade mobile (`StateDeputyFilters`, espelho de `TerritoryFilters`) entrou por decisão desta sessão, fora do appetite original. Redirect canônico em duas fases (`resolveStateDeputyListUrl` → `totalPages` → resolve de novo) no molde de `municipios/page.tsx`. Sort v1 = Nome (default) e Partido; filtro v1 = Partido (multi + "Sem partido"); `municipalityCount`/`leadershipCount` seguem sem sort/filtro, como travado no plano. Gate verde (tsc, lint, format, check:cycles, 396 testes, build); Aikido 0 achados. **Correção pós-entrega no mesmo dia (design review independente):** mobile não tinha a opção "Sem partido" que o popover desktop já injetava (`hasNoParty` não chegava a `StateDeputyFilters`) e a tabela não passava `caption` ao `CampaignTable`, ao contrário dos dois precedentes citados no plano — ambos corrigidos no mesmo molde; copy do empty state também virou plural. **`/simplify` no mesmo dia:** a correção acima tinha copiado a montagem da linha "Sem partido" em vez de compartilhá-la — extraído `buildStateDeputyPartyOptions` em `stateDeputyListFilters.ts`, consumido por `StateDeputyFilters` e `StateDeputyHeaderFilter`; `StateDeputySortableHead` perdeu o discriminante `filterParam: 'party'` (só há uma coluna filtrável, `filterOptions` já basta); `parsePartiesParam` parou de reimplementar o dedupe de `allParamValues`; um `if` sempre-verdadeiro saiu de `buildStateDeputyListWhere`; `loadStateDeputyListPageData` ganhou `select` na query principal. Gate reverificado verde. **`/simplify` pós-`/rebase-on-main` (2ª rodada, mesmo dia):** rodou de novo sobre o commit squashado — removeu tipo+cast morto `StateDeputyPartyRow` (`stateDeputyData.ts`, `select` só afeta runtime, não a checagem estática), `stateDeputyColumns` (`page.tsx`) passou a importar `StateDeputyFilterOption` em vez de redeclarar a forma, `parsePartiesParam` foi inlineado (helper de uma linha usado uma vez). Achado maior: `firstPartyNamesLabel` era a 3ª cópia byte-idêntica da mesma função de truncar nomes (`firstTerritoryNamesLabel` em `territoryListFilters.ts`, `firstNamesLabel` em `municipalityListFilters.ts`) — cruzou o limiar de "3+ call sites"; extraído `truncatedNamesLabel` em `campaignListUrl.ts`, os três domínios migrados. Recomendação de hook compartilhado para o scaffold de busca debounced (`StateDeputyFilters`/`TerritoryFilters`/`MunicipalityFilters` reimplementam o mesmo ~50 linhas) registrada mas não perseguida — tocaria os três domínios, fora do escopo deste item. Gate reverificado verde (tsc, lint, format, check:cycles, 396 testes, build); Aikido 0 achados. Plano: [`ordenacao-filtros-lista-dobradinhas.md`](../../../docs/plans/ordenacao-filtros-lista-dobradinhas.md). - **B27 entregue em código (2026-07-26):** combobox + chips + auto-save no popover de Assessores de `/campanha/municipios` — trocou a lista de checkboxes + botão "Salvar assessores" por chips removíveis (`Badge`+`XIcon`, `aria-label="Remover "`) acima de um `Command` (`shouldFilter={false}` + `matchesAtWordStart`, acento-insensível), com gravação **por delta** (um toggle por vez, não replace do array). Fase 1 — `nextAdvisorIdsAfterMembership` subiu de `actions/advisor.ts` para `src/lib/municipalityAdvisorMembership.ts` (compartilhado com a carteira do B19); `setMunicipalityAdvisorMembershipRecord`/`setMunicipalityAdvisorMembership` em `actions/municipality.ts` (mesma transação + lock `municipality-advisors:{id}` + `reloadUnrestrictedActor`, mas sem `assertTargetAdvisor` — o alvo pode ser coordenador/candidato, legal nesta tela). Fase 2 — endpoint `POST /campanha/municipios/advisors` espelhando `expected-votes/route.ts` (`isSameOriginRequest` local, zod, `mapCampaignFormActionError`). Fase 3 — `MunicipalityListAdvisorsControl.tsx` reescrito: estado otimista + contador de pendências (reconcilia só quando toda entrega em voo assentou, nunca aborta um delta anterior), `Alert` de erro, `aria-live` `sr-only`; polish do `/impeccable` alinhou o gatilho (`aria-expanded`/`aria-haspopup`/realce `bg-muted/60` ao abrir) ao padrão do `MunicipalityListExpectedVotesControl` irmão (B24). `assignMunicipalityAdvisorsRecord` (replace do array) sobrevive só para `/editar`. Achado de infraestrutura no caminho: o Playwright `webServer` (`pnpm dev`) recompila uma rota POST-only no primeiro hit e o Fast Refresh resultante aborta o fetch em voo do teste (`ERR_ABORTED`) — `tests/e2e/setup.e2e.spec.ts` ganhou um prewarm dummy de `/campanha/municipios/advisors` (e retroativamente de `expected-votes/`, mesmo padrão) antes dos specs reais. Gate verde: tsc/lint/format/`check:cycles` limpos, 393 unit+int (6 novos), e2e do fluxo (isolado e em suíte) verde — falhas observadas em rodadas full-suite eram flakiness de ambiente sob carga sustentada (testes não relacionados falhando de forma rotativa, sempre verdes isolados), não regressão do B27; build produção ok; Aikido 0 achados nos 9 arquivos novos/editados. Plano: [`combobox-assessores-lista-municipios.md`](../../../docs/plans/combobox-assessores-lista-municipios.md). - **B23 entregue em código (2026-07-26):** tooltip de conteúdo nas células das listas — a mecânica genérica (`cellTooltip` em `CampaignTableColumn`, `CampaignCellTooltip`, `CampaignHoverTooltip` promovido para `shared/`) já tinha saído de graça no **E10 ✓**; este item fechou o que faltava: `openOnTouch`/`disabled` em `CampaignHoverTooltip` (default `true`/`false`, sem mudança nos 4 call sites pré-B23) para conviver com um Popover no mesmo gatilho sem roubar o tap nem sobreviver atrás dele — e, no `/simplify`, ganhou um terceiro comportamento: `content` falsy renderiza o `children` sem wrapper, então os dois consumidores abaixo não precisam mais de um ternário `content ? … : children` cada; `formatAdvisorNamesTooltip` + `advisorEntriesFromIds` (dedupe do id→nome entre a lista e o controle) junto de `MunicipalityAdvisorAvatarStack.tsx`; e os dois consumidores de `/campanha/municipios` — célula "Assessores" (não-coordenador via `cellTooltip`; coordenador envolvendo o próprio `PopoverTrigger`, `openOnTouch={false}`/`disabled={open}`, nunca os dois ao mesmo tempo) e célula "Tendência" (tooltip só quando há `politicalTrendNote`; em toque a nota aparece como leitura dentro do próprio Popover, acima do select). Verificado manualmente em `/campanha/municipios` como coordenador e assessor: hover mostra nomes/justificativa, foco de teclado abre a mesma tooltip, clique/tap abre o Popover sem tooltip órfã atrás. Gate verde (tsc/lint/format/knip — P3 pré-existente/check:cycles/443 unit/387 int/build); Aikido 0 findings. Plano: [`tooltip-celulas-listas.md`](../../../docs/plans/tooltip-celulas-listas.md). - **B22 entregue em código (2026-07-26):** explicação por coluna no header de `/campanha/municipios` — `description?: ReactNode` em `CampaignTableHead` (`shared/CampaignTable.tsx`) e rename `tooltip`→`description` em `MunicipalitySortableHead`, ambos com o mesmo sublinhado pontilhado no label e o mesmo `CampaignHoverTooltip` compartilhado (já promovido para `shared/` pelo **E10 ✓**) — sem componente novo. Auditoria pré-implementação achou o escopo já reduzido: o primitivo de tooltip e o `cellTooltip` genérico já existiam (E10), então só faltava a prop em `CampaignTableHead` e o rename. Novo helper `campaignConceptOneLiner(id)` ao lado de `campaignConceptHref` em `campaignIntelligenceConcepts.ts` — fonte única para as colunas cobertas pelo **E18 ✓** (`expectedVotes` lê `oneLiner('meta')`, `goalCoverage` lê `oneLiner('cobertura-da-meta')` + a nota de cenário). Novo `MunicipalityListColumnId` + `municipalityColumnDescriptions` (`Record` de 11 entradas) em `municipalityLabels.ts` — a coluna "Classe" mantém o texto mais longo (`CLASS_COLUMN_DESCRIPTION`, movido de `MunicipalityList.tsx`) em vez do `oneLiner` terso de `classe-territorial`, decisão assumida porque uma classificação de 5 vias não cabe numa frase. `MunicipalityList.tsx` perdeu os três `*Hint` locais e a intersecção de tipo extra em `municipalityListColumns()`. Testes: pin do `campaignConceptOneLiner`, `describe('CampaignTableHead description')` (sem prop = render idêntico; com prop = 1 trigger, conteúdo fora do markup do servidor, mesmo contrato do `cellTooltip`), e contagem de `data-slot="tooltip-trigger"` no `` (10 staff / 5 leader) em `campaignComponents.unit.spec.ts`. Gate verde (tsc, lint, format, check:cycles, 442 unit / 387 int, build); `knip` segue com o erro pré-existente ao carregar `payload.config.ts` (ledgerado P3, confirmado idêntico fora desta mudança); Aikido sem achado. Fora do escopo (mantido do plano): copy nas outras 5 listas, tooltip nos cards mobile, variante Popover no touch. Plano: [`explicacao-colunas-header-listas.md`](../../../docs/plans/explicacao-colunas-header-listas.md). diff --git a/docs/plans/ordenacao-filtros-lista-dobradinhas.md b/docs/plans/ordenacao-filtros-lista-dobradinhas.md index 368cddd4..acd38d8f 100644 --- a/docs/plans/ordenacao-filtros-lista-dobradinhas.md +++ b/docs/plans/ordenacao-filtros-lista-dobradinhas.md @@ -1,7 +1,7 @@ # B33 — Ordenação e filtro no header da lista de dobradinhas -Status: rascunho -Atualizado em: 2026-07-25 +Status: entregue em código +Atualizado em: 2026-07-26 Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B — superfícies de coordenação, item B33) Impeccable: B — encaixe em tela existente (`/campanha/dobradinhas`), sem rota nova; consome o header rico compartilhado entregue pelo **B21 ✓** Appetite: ~0,5–0,75 dia eng; `shared/CampaignSortableHead`/`CampaignHeaderFilterPopover` já existem @@ -9,6 +9,24 @@ Responsável: — **Atualização B21 (2026-07-25):** a corrida de ordem de chegada foi encerrada. B33 não extrai nem duplica chrome; implementa somente `StateDeputySortableHead`/`StateDeputyHeaderFilter` e seus módulos de URL/filtros. +## Revisão na entrega (2026-07-26) + +Auditoria do plano contra o repositório antes de codar achou cinco divergências, todas corrigidas na implementação (não no plano, que fica congelado como registro da decisão original): + +1. **`leadershipListUrl.ts` não existe** (B29 não entregou) — espelhado `municipalityListUrl.ts` (paginação, clamp via `resolveListUrl`) e `territoryListUrl.ts` (chaves/labels de sort) em vez de um módulo inexistente. +2. **Ordem dos nulos em `party` não é "nulls por último em ambas as direções"** — pinado por int test o comportamento real do adapter Postgres/Payload: ASC ordena nulls por último, DESC ordena nulls primeiro. O caminho direto para achar as fichas sem partido continua sendo o filtro "Sem partido" (`party: { exists: false }`), não a posição na ordenação. +3. **Paridade mobile entrou** (decisão desta sessão, +~0,25d sobre o appetite) — `StateDeputyFilters.tsx` espelha `TerritoryFilters.tsx`: busca com debounce, resumo de filtros + "Limpar", bloco `md:hidden` com `CampaignMobileMultiFilterField` (Partido) e `NativeSelect` "Ordenar". +4. **`parseStateDeputyListParams` saiu do `describe.each` compartilhado** de `tests/unit/campaignEntityListParsers.unit.spec.ts` (cresceu um contrato de sort/filtro próprio) e ganhou spec dedicada em `tests/unit/stateDeputyListUrl.unit.spec.ts`. +5. **Redirect canônico em duas fases** (`resolveStateDeputyListUrl(params)` → carrega `totalPages` → `resolveStateDeputyListUrl(params, totalPages)`) implementado no molde de `municipios/page.tsx`, evitando `?page=9` fora do range. + +**Correção pós-entrega (2026-07-26, achada por design review independente):** dois defeitos reais — `StateDeputyFilters` (mobile) montava as opções de "Partido" só a partir da faceta, sem a sentinela "Sem partido" que o popover desktop já injetava (`hasNoParty`), então um usuário só-mobile não conseguia filtrar/desmarcar esse valor mesmo vendo-o no resumo de filtros ativos; e `CampaignTable` não recebia `caption` nesta lista, ao contrário dos dois precedentes citados no plano (`MunicipalityList`/`TerritoryList` sempre populam esse prop) — um usuário navegando por landmark de tabela (leitor de tela) não tinha nome/descrição acessível. Ambos corrigidos no mesmo padrão dos precedentes (`hasNoParty` propagado a `StateDeputyFilters`, `caption={sortSummary + descrição}` na tabela); copy do empty state também corrigida de singular ("a dobradinha") para plural ("as dobradinhas"). Gate verde de novo (tsc, lint, format, check:cycles, 396 testes, build); Aikido 0 achados nos 2 arquivos tocados. + +**`/simplify` (2026-07-26):** três agentes de revisão paralelos (qualidade, performance, reuso) apontaram a mesma duplicação de fundo — a linha sentinela "Sem partido" era montada separadamente em `StateDeputyFilters` e `StateDeputyHeaderFilter` (a correção pós-entrega acima copiou a lógica em vez de compartilhá-la). Extraído `buildStateDeputyPartyOptions` em `stateDeputyListFilters.ts`, consumido pelos dois; `noPartyFilterLabel` deixou de ser exportado (só usado internamente agora). Outras três limpezas pontuais: `StateDeputySortableHead` derrubou o discriminante `filterParam: 'party'` — só há uma coluna filtrável, então a presença de `filterOptions` já basta (ao contrário de Territory, que tem duas); `parsePartiesParam` parou de reimplementar dedupe que `allParamValues` já faz; `buildStateDeputyListWhere` perdeu um `if (partyFilters.length)` sempre-verdadeiro (todo `parties` não vazio produz ao menos um filtro nomeado ou a sentinela). Performance: `loadStateDeputyListPageData` ganhou `select: { name, slug, party }` na query principal (a lista já projetava a faceta assim; a query da página não). Gate reverificado verde (tsc, lint, format, check:cycles, 396 testes, build); Aikido 0 achados nos 7 arquivos tocados. Recomendações descartadas por serem debate estilístico ou fora de escopo: inlinear `defaultStateDeputyListSortDir`/`firstPartyNamesLabel` (mantidos por paridade com `territoryListUrl`/`territoryListFilters`); `Set` em vez de `.includes()` no loop de contagens (pré-existente, não tocado por este item); agregado SQL para o facet de partido (só compensa em escala, travado como Adiado com gatilho). + +**`/simplify` pós-rebase (2026-07-26, 2ª rodada):** rodada nova pedida após `/rebase-on-main`, sobre o commit já squashado. Três achados de qualidade corrigidos: `StateDeputyPartyRow` (`Pick`) era um tipo + cast morto em `stateDeputyData.ts` — `result.docs` já vem tipado `StateDeputy[]`, `select` só afeta o runtime, não a checagem estática; removido. `stateDeputyColumns` (`page.tsx`) redeclarava `Array<{ value: string; label: string }>` em vez de importar `StateDeputyFilterOption`, já exportado por `stateDeputyListFilters.ts`. `parsePartiesParam` era um helper de uma linha usado uma única vez — inlineado no call site. Um achado de reuso maior: `firstPartyNamesLabel` (aqui) era a **terceira** cópia byte-idêntica da mesma função de truncar nomes (`firstTerritoryNamesLabel` em `territoryListFilters.ts`, `firstNamesLabel` em `municipalityListFilters.ts`) — cruzou o limiar de "3+ call sites" do `engineering-standards.mdc`. Extraído `truncatedNamesLabel` em `campaignListUrl.ts` (o módulo-base que os três arquivos de filtro já importam) e os três call sites migrados; nenhuma das duas listas irmãs precisou de mudança de comportamento. Performance: zero achados (dataset de dezenas de linhas, tudo já em `Promise.all`/`select` estreito da rodada anterior). Recomendação descartada por exigir refatoração muito maior que este item: o reuse reviewer também achou que `StateDeputyFilters`/`TerritoryFilters`/`MunicipalityFilters` reimplementam o mesmo scaffold de busca debounced + navegação (~50 linhas cada) — candidato a um hook compartilhado (`useCampaignListFilterNavigation`), mas tocaria os três domínios fora do escopo deste item; registrado aqui para um fill-in futuro, não perseguido agora. Gate reverificado verde (tsc, lint, format, check:cycles, 396 testes, build); Aikido 0 achados nos 7 arquivos tocados nesta rodada. + +**Entregue:** `src/utilities/stateDeputyListUrl.ts` + `stateDeputyListFilters.ts` (contrato de URL/filtro, sentinela `NO_PARTY_FILTER_VALUE` para "Sem partido"); `stateDeputyData.ts` com `where`/`sort` resolvidos e facet de partido (`loadStateDeputyPartyFacet`, mesmo contrato de `loadMunicipalityListFilterFacets` — respeita a busca, ignora o próprio filtro do popover); `StateDeputySortableHead`/`StateDeputyHeaderFilter`/`StateDeputyFilters` (client, wrappers finos sobre `shared/CampaignSortableHead`/`CampaignHeaderFilterPopover`); `dobradinhas/page.tsx` com redirect canônico, `CampaignTable` sempre montada (`empty={…}`) e resumo de ordenação `aria-live`. Sort v1 = Nome (default) e Partido; filtro v1 = Partido (multi + "Sem partido"); contagens derivadas (`municipalityCount`/`leadershipCount`) seguem sem sort/filtro, conforme travado no plano. Gate verde (tsc, lint, format, check:cycles, 396 testes, build); `knip` com o erro pré-existente ao carregar `payload.config.ts` (ledgerado P3, não introduzido por esta entrega); Aikido 0 achados nos 10 arquivos novos/editados. + ## Design (Impeccable) Âncoras: `PRODUCT.md` (princípios 2, 3 e 4; anti-goals §5) / `DESIGN.md` (register `product`, Field Desk) · tema `data-theme='campaign'` · sistema de listas do Pass 2 W1 (`CampaignTable`, `CampaignSearchForm`, `CampaignListFooter`, `CampaignListEmptyState`, `CampaignListPendingBoundary`) · header rico entregue em B15 ✓/B16 ✓ (`MunicipalitySortableHead`, `MunicipalityHeaderFilter`) e o par compartilhado que **B29** promove para `shared/`. diff --git a/docs/roadmap.md b/docs/roadmap.md index 020b7997..6784644f 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap — Teqo -Atualizado em: 2026-07-26 (**B28 ✓** e-mail + celular na lista de lideranças entregue — colunas E-mail/Celular com copy-on-click + coluna de Ações com WhatsApp, no padrão de `/campanha/assessores` (B19 ✓); `shared/CampaignCopyableCell` nasce compartilhado já no 2º call site (`AdvisorsTable` migrado no mesmo PR), `whatsAppHrefForPhone` promovido a `lib/phone.ts`, `loadLeadershipDetail` deduplicado; [plano](plans/email-celular-lista-liderancas.md); **B24 ✓** auto-save + justificativa no popover de Tendência de `/campanha/municipios` — sem botão "Salvar", `POST /campanha/municipios/political-trend`, [plano](plans/autosave-tendencia-lista-municipios.md); **B13 ✓** escala relativa no mapa do Início entregue — quantis como novo default, padrão próprio/LQ nas âncoras do E10 e posição no município a partir do **artefato TSE v3** (`federalRankByIbgeCode`), mais bolhas opcionais por válidos projetados coloridas pela classe; `computeAggregateTerritorialClass` nasce aqui e o **E12** deve herdá-lo em vez de escrever um rollup próprio; `TERRITORIAL_CLASS_ANCHORS` mudou de casa para `src/lib/territorialClassAnchors.ts`; [plano](plans/escala-relativa-mapa.md); **B13+** novo fill-in de engenharia com os quatro débitos do `/simplify` do B13 — teste de concordância entre as duas escritas do LQ (mapa cliente × classificador server-only, a rede de segurança do **E15**), escopo geográfico declarado no readout (posição é da cidade inteira, classe é do escopo do usuário), uma varredura da fatia TSE por ano no script de agregados em vez de três, e a tupla no `competitiveRankByYear` que corta ~metade dos ~63,6 KB somados ao payload RSC · [plano](plans/escala-dry-pos-b13.md); **débito de teste** afinado no ledger ([TECH-DEBT.md](TECH-DEBT.md), P3 → P2): `campaignMunicipalities`/`campaign-pwa` são flaky com 2 workers **em `main`** também — clique no checkbox de consentimento antes da hidratação e regex de fixture em que "Conde" casa com "Condeúba"; **B27 ✓** combobox + chips + auto-save no popover de Assessores de `/campanha/municipios` entregue — a checkbox list + botão "Salvar assessores" virou chips removíveis + `Command` com gravação por delta, `nextAdvisorIdsAfterMembership` movido para `src/lib/`, endpoint `POST /campanha/municipios/advisors` espelho do `expected-votes/`, [plano](plans/combobox-assessores-lista-municipios.md); **B23 ✓** tooltip de conteúdo nas células das listas entregue — a mecânica genérica (`cellTooltip`, `CampaignCellTooltip`, `CampaignHoverTooltip` em `shared/`) já tinha saído de graça no E10 ✓; este item fechou `openOnTouch`/`disabled` no primitivo (mais um terceiro comportamento do `/simplify`: `content` falsy renderiza o trigger sem wrapper) e os dois consumidores de `/campanha/municipios` (nomes de assessores + justificativa da tendência), [plano](plans/tooltip-celulas-listas.md); **B22 ✓** explicação por coluna no header das listas entregue — `description?: ReactNode` em `CampaignTableHead`/`MunicipalitySortableHead` (rename de `tooltip`, sublinhado pontilhado), `campaignConceptOneLiner` como fonte única para colunas cobertas pelo E18 ✓, `municipalityColumnDescriptions` cobrindo as 11 colunas de `/campanha/municipios`; sem migration/action, [plano](plans/explicacao-colunas-header-listas.md); 2026-07-25: **E10 ✓** classificação territorial relativa entregue — Reduto/Expansão/Manutenção/Marginal/Sem base sobre o artefato TSE, coluna+sort+filtro na lista, badge no card do E8 e na capa do dossiê, e o seam genérico `cellTooltip` da `CampaignTable` (núcleo do B23) com `CampaignHoverTooltip` promovido para `shared/`; **B13** liberado; **E10+** novo fill-in de engenharia com os quatro débitos do `/simplify` do E10 — a lista lendo `municipality` duas vezes por request (o que **dispara o "Adiado com gatilho" do A11**: `classe` é o 4º path derivado no loader), memo de processo dos potenciais por município, `TooltipProvider` único no layout e os multi-filtros do mobile saindo das definitions · [plano](plans/escala-dry-pos-e10.md); **C8 F4b** novo fill-in — o guard de `formActions` só vê arquivos chamados exatamente `formActions.ts`, achado do `/simplify` do C13; **B26 ✓** registrar sinal na célula "Último sinal" — [plano](plans/registrar-sinal-lista-municipios.md); **C13 ✓** "Plano de Ação" → **"Atividade"** renomeado em toda a vertical — collection `activity`, rota `/campanha/atividades`, sidebar "Atividades", migration data-preserving escrita à mão (4 tabelas, 3 enums, 2 colunas, 39 índices, 14 FKs, 2 sequences + os 8 índices fósseis `%plaza%` da remodelagem) e guard de vocabulário table-driven varrendo `src`+`tests`+`scripts`; [plano](plans/renomear-plano-acao-para-atividade.md); **B37** chips editáveis de municípios na lista de dobradinhas — coluna "Municípios" de `/campanha/dobradinhas` troca o número morto (`municipalityCount`) por chips com link (município) / território (`MapPinIcon`) e o mesmo Popover+combobox do **B34** (busca por município/TI/ZE, lote de território/zona), mas escrevendo do lado invertido (`municipality.stateDeputies`, sem piso mínimo, lock por município em vez de por dobradinha, sem `assertMunicipalitiesWithinScope` porque `canUpdateMunicipality` já escopa o assessor); reaproveita/promove a mesma lib do B19/B34 (quem chegar primeiro faz a renomeação); mesmo conflito registrado com o pedido literal (toggle "Editar" de tabela inteira) que B28/B31/B32/B34/B36 já resolveram na direção do Popover por célula — decisão marcada como _proposta, a validar com produto_; sem migration, [plano](plans/chips-municipios-lista-dobradinhas.md); **B36** coluna "Lideranças" em `/campanha/dobradinhas` com a mesma UI/mecânica da coluna "Municípios" de `/campanha/assessores` (B19 ✓) — chips + Popover + `Command` + auto-save por delta + "Ver mais…" (clamp de 3 linhas por `ResizeObserver`, necessário aqui porque um deputado popular pode acumular muito mais lideranças do que uma liderança acumula dobradinhas); pedido explícito de não duplicar lógica — reusa **sem alteração** a mutação/schema/lock que o **B31** desenha para o lado inverso da mesma relação `leadership.stateDeputies`, promovendo a célula desse item para um componente compartilhado bidirecional (`LeadershipStateDeputyRelationCell`) em vez de um segundo componente-irmão; corrida de extração com o B31 (quem entrar primeiro paga a peça compartilhada); sem migration, [plano](plans/chips-liderancas-lista-dobradinhas.md); **B34** chips editáveis de municípios na lista de lideranças — coluna "Municípios" de `/campanha/liderancas` ganha chips com link (município) / território (`MapPinIcon`), edição por **Popover disparado por célula** com chips removíveis + combobox `Command` (busca por município/TI/ZE, lote de território/zona), gravação **por delta** direto em `leadership.municipalities` com lock `leadership-municipalities:{id}` (piso de 1 e teto de 30 respeitados) e `revalidatePath` (`liderancas`, `liderancas/[id]`, `municipios/[slug]` dos tocados); promove a lógica pura de `advisorMunicipalityPortfolio.ts`/`loadAdvisorMunicipalityIndex` (**B19 ✓**) para nomes neutros, sem portar a UI com `ResizeObserver`; **conflito registrado com o pedido literal** (pede um toggle "Editar" de tabela inteira, que B28/B31/B32 já travaram como anti-goal nesta mesma tabela — este plano segue o precedente e marca a decisão como _proposta, a validar com produto_); sem migration, [plano](plans/chips-municipios-lista-liderancas.md); **B33** ordenação e filtro no header da lista de dobradinhas — leva B15 ✓/B16 ✓ para `/campanha/dobradinhas` (sort por Nome/Partido, filtro por Partido); consome o header rico compartilhado que **B29** extrai (ou extrai o dele mesmo, se chegar primeiro — mesma corrida de ordem de chegada de B21/B29) — Não escopo do B29 já nomeava esta entrada; contagens derivadas (`municipalityCount`/`leadershipCount`) ficam fora do sort v1, [plano](plans/ordenacao-filtros-lista-dobradinhas.md); **B32** editar Status de apoio na célula da lista de lideranças — clicar na célula abre um Popover com select de `supportStatus`, gravando sozinho por endpoint JSON dedicado (`POST /campanha/liderancas/support-status`, espelho do `expected-votes/`), **sem** o toggle "Editar" de tabela inteira (pedido explícito de produto, mesmo anti-goal do B28/B31); fecha o "Adiado com gatilho" que o próprio B29 já havia registrado para este caso; extrai `isSameOriginRequest` (2º endpoint) e as mensagens seguras do form action da ficha para módulos compartilhados; sem migration, [plano](plans/autosave-status-lista-liderancas.md); **B31** coluna de dobradinhas na lista de lideranças — chips com link + adicionar/remover na própria linha, com busca e gravação automática, na paridade pedida com a coluna "Municípios" de `/campanha/assessores` (B19 ✓) — paridade **da coluna**, não do modo "Editar" de tabela inteira (anti-goal do princípio 3 e do B28); escrita **por delta** com lock (`leadership-state-deputies:{id}`) em vez de replace do array, e `revalidatePath` porque `/campanha/dobradinhas` tem contagens derivadas; sem migration, [plano](plans/dobradinhas-lista-liderancas.md); **B30** convite de completar cadastro por WhatsApp na lista de lideranças — ação ao fim de cada linha de `/campanha/liderancas` reusando `createCampaignInvite`/`kind: 'autopreenchimento'` (mesmo fluxo do detalhe), disabled quando `contact.phone` não normaliza (`normalizeBrazilianPhone`); ícone distinto do WhatsApp de contato do B28 (soft, mesma linha); sem migration/action nova, [plano](plans/convite-whatsapp-lista-liderancas.md); **B29** ordenação e filtro no header da lista de lideranças — leva B15 ✓/B16 ✓ para `/campanha/liderancas` (sort/filtro na URL, popover no header, colunas "Setor" e "Última atualização"), ordenação por nome via join `contact.name` (sem desnormalizar), e é o **3º call site** que dispara a extração do par head/filtro para `shared/` (municípios migrado junto; B21 fecha seu adiado), [plano](plans/ordenacao-filtros-lista-liderancas.md); ~~**B28**~~ ✓ e-mail + celular na lista de `/campanha/liderancas` (entregue 2026-07-26) — colunas E-mail/Celular com copy-on-click + coluna de Ações com WhatsApp como em `/campanha/assessores` (B19 ✓); `CampaignCopyableCell` nasce compartilhado já no 2º call site (`AdvisorsTable` migrado no mesmo PR), `whatsAppHrefForPhone` promovido a `lib/phone.ts`; edição in-list adiada; sem migration, [plano](plans/email-celular-lista-liderancas.md); ~~**B27**~~ ✓ combobox + chips + auto-save no popover de Assessores de `/campanha/municipios` (entregue 2026-07-26), [plano](plans/combobox-assessores-lista-municipios.md); ~~**B26**~~ ✓ registrar sinal na célula "Último sinal" (entregue 2026-07-25), [plano](plans/registrar-sinal-lista-municipios.md); **fill-in** renomear coluna "Cobertura da meta" → "Cobertura" em `/campanha/municipios` — header curto (padrão A11); glossário/KPI strips intactos; B16 ✓ liberou o nome ao remover "Assessoria", [plano](plans/renomear-coluna-cobertura-meta.md); **B25** link do território na lista de municípios — a célula da coluna "Território" vira link para a linha daquele TI em `/campanha/territorios` (âncora, sem rota de detalhe); dep dura B21, [plano](plans/link-territorio-lista-municipios.md); ~~**B24**~~ ✓ auto-save + justificativa editável no popover de Tendência de `/campanha/municipios` (entregue 2026-07-26), [plano](plans/autosave-tendencia-lista-municipios.md); **fill-in** remover coluna "Tipo" de `/campanha/municipios` — município e ZE tratados iguais na coordenação; filtro por Salvador cobre as zonas; drop também do filtro/sort/`?kind=` na URL da lista, schema `kind` intacto, [plano](plans/remover-coluna-tipo-municipios.md); ~~**B23**~~ ✓ tooltip de conteúdo nas células das listas (entregue 2026-07-26), [plano](plans/tooltip-celulas-listas.md); **fill-in** opções selecionadas no topo nos Popovers de filtro do header — helper puro reutilizável pelas demais listas, [plano](plans/selecionados-no-topo-filtros-header.md); ~~**B22**~~ ✓ explicação por coluna no header das listas (entregue 2026-07-26), [plano](plans/explicacao-colunas-header-listas.md); **B21** página própria dos Territórios de Identidade — rota `/campanha/territorios` com overview + tabela no sistema de listas (sort/filtro no header), sem migration, [plano](plans/pagina-territorios-identidade.md); **B20 ✓** Municípios prioritários no Início entregue 2026-07-25 — atalho ranqueado por déficit + markup sem bullets, [plano](plans/municipios-prioritarios-inicio.md); **Pass 2 ✓** consolidação de engenharia — listas genéricas/B16+ absorvido/B17-B18 triviais/E10 limpo/C8 F4 ✓/D3 consent WhatsApp; **B16 ✓**; **E16 ✓**; **E9 ✓**; **E18 ✓**; **B19 ✓**; **E8 ✓**; C12 ✓; B18; B17; A11 ✓; B15 ✓; E4R ✓; E17 ✓; B14; janela 1: smoke, R6) +Atualizado em: 2026-07-26 (**B28 ✓** e-mail + celular na lista de lideranças entregue — colunas E-mail/Celular com copy-on-click + coluna de Ações com WhatsApp, no padrão de `/campanha/assessores` (B19 ✓); `shared/CampaignCopyableCell` nasce compartilhado já no 2º call site (`AdvisorsTable` migrado no mesmo PR), `whatsAppHrefForPhone` promovido a `lib/phone.ts`, `loadLeadershipDetail` deduplicado; [plano](plans/email-celular-lista-liderancas.md); **B24 ✓** auto-save + justificativa no popover de Tendência de `/campanha/municipios` — sem botão "Salvar", `POST /campanha/municipios/political-trend`, [plano](plans/autosave-tendencia-lista-municipios.md); **B13 ✓** escala relativa no mapa do Início entregue — quantis como novo default, padrão próprio/LQ nas âncoras do E10 e posição no município a partir do **artefato TSE v3** (`federalRankByIbgeCode`), mais bolhas opcionais por válidos projetados coloridas pela classe; `computeAggregateTerritorialClass` nasce aqui e o **E12** deve herdá-lo em vez de escrever um rollup próprio; `TERRITORIAL_CLASS_ANCHORS` mudou de casa para `src/lib/territorialClassAnchors.ts`; [plano](plans/escala-relativa-mapa.md); **B13+** novo fill-in de engenharia com os quatro débitos do `/simplify` do B13 — teste de concordância entre as duas escritas do LQ (mapa cliente × classificador server-only, a rede de segurança do **E15**), escopo geográfico declarado no readout (posição é da cidade inteira, classe é do escopo do usuário), uma varredura da fatia TSE por ano no script de agregados em vez de três, e a tupla no `competitiveRankByYear` que corta ~metade dos ~63,6 KB somados ao payload RSC · [plano](plans/escala-dry-pos-b13.md); **débito de teste** afinado no ledger ([TECH-DEBT.md](TECH-DEBT.md), P3 → P2): `campaignMunicipalities`/`campaign-pwa` são flaky com 2 workers **em `main`** também — clique no checkbox de consentimento antes da hidratação e regex de fixture em que "Conde" casa com "Condeúba"; **B27 ✓** combobox + chips + auto-save no popover de Assessores de `/campanha/municipios` entregue — a checkbox list + botão "Salvar assessores" virou chips removíveis + `Command` com gravação por delta, `nextAdvisorIdsAfterMembership` movido para `src/lib/`, endpoint `POST /campanha/municipios/advisors` espelho do `expected-votes/`, [plano](plans/combobox-assessores-lista-municipios.md); **B23 ✓** tooltip de conteúdo nas células das listas entregue — a mecânica genérica (`cellTooltip`, `CampaignCellTooltip`, `CampaignHoverTooltip` em `shared/`) já tinha saído de graça no E10 ✓; este item fechou `openOnTouch`/`disabled` no primitivo (mais um terceiro comportamento do `/simplify`: `content` falsy renderiza o trigger sem wrapper) e os dois consumidores de `/campanha/municipios` (nomes de assessores + justificativa da tendência), [plano](plans/tooltip-celulas-listas.md); **B22 ✓** explicação por coluna no header das listas entregue — `description?: ReactNode` em `CampaignTableHead`/`MunicipalitySortableHead` (rename de `tooltip`, sublinhado pontilhado), `campaignConceptOneLiner` como fonte única para colunas cobertas pelo E18 ✓, `municipalityColumnDescriptions` cobrindo as 11 colunas de `/campanha/municipios`; sem migration/action, [plano](plans/explicacao-colunas-header-listas.md); 2026-07-25: **E10 ✓** classificação territorial relativa entregue — Reduto/Expansão/Manutenção/Marginal/Sem base sobre o artefato TSE, coluna+sort+filtro na lista, badge no card do E8 e na capa do dossiê, e o seam genérico `cellTooltip` da `CampaignTable` (núcleo do B23) com `CampaignHoverTooltip` promovido para `shared/`; **B13** liberado; **E10+** novo fill-in de engenharia com os quatro débitos do `/simplify` do E10 — a lista lendo `municipality` duas vezes por request (o que **dispara o "Adiado com gatilho" do A11**: `classe` é o 4º path derivado no loader), memo de processo dos potenciais por município, `TooltipProvider` único no layout e os multi-filtros do mobile saindo das definitions · [plano](plans/escala-dry-pos-e10.md); **C8 F4b** novo fill-in — o guard de `formActions` só vê arquivos chamados exatamente `formActions.ts`, achado do `/simplify` do C13; **B26 ✓** registrar sinal na célula "Último sinal" — [plano](plans/registrar-sinal-lista-municipios.md); **C13 ✓** "Plano de Ação" → **"Atividade"** renomeado em toda a vertical — collection `activity`, rota `/campanha/atividades`, sidebar "Atividades", migration data-preserving escrita à mão (4 tabelas, 3 enums, 2 colunas, 39 índices, 14 FKs, 2 sequences + os 8 índices fósseis `%plaza%` da remodelagem) e guard de vocabulário table-driven varrendo `src`+`tests`+`scripts`; [plano](plans/renomear-plano-acao-para-atividade.md); **B37** chips editáveis de municípios na lista de dobradinhas — coluna "Municípios" de `/campanha/dobradinhas` troca o número morto (`municipalityCount`) por chips com link (município) / território (`MapPinIcon`) e o mesmo Popover+combobox do **B34** (busca por município/TI/ZE, lote de território/zona), mas escrevendo do lado invertido (`municipality.stateDeputies`, sem piso mínimo, lock por município em vez de por dobradinha, sem `assertMunicipalitiesWithinScope` porque `canUpdateMunicipality` já escopa o assessor); reaproveita/promove a mesma lib do B19/B34 (quem chegar primeiro faz a renomeação); mesmo conflito registrado com o pedido literal (toggle "Editar" de tabela inteira) que B28/B31/B32/B34/B36 já resolveram na direção do Popover por célula — decisão marcada como _proposta, a validar com produto_; sem migration, [plano](plans/chips-municipios-lista-dobradinhas.md); **B36** coluna "Lideranças" em `/campanha/dobradinhas` com a mesma UI/mecânica da coluna "Municípios" de `/campanha/assessores` (B19 ✓) — chips + Popover + `Command` + auto-save por delta + "Ver mais…" (clamp de 3 linhas por `ResizeObserver`, necessário aqui porque um deputado popular pode acumular muito mais lideranças do que uma liderança acumula dobradinhas); pedido explícito de não duplicar lógica — reusa **sem alteração** a mutação/schema/lock que o **B31** desenha para o lado inverso da mesma relação `leadership.stateDeputies`, promovendo a célula desse item para um componente compartilhado bidirecional (`LeadershipStateDeputyRelationCell`) em vez de um segundo componente-irmão; corrida de extração com o B31 (quem entrar primeiro paga a peça compartilhada); sem migration, [plano](plans/chips-liderancas-lista-dobradinhas.md); **B34** chips editáveis de municípios na lista de lideranças — coluna "Municípios" de `/campanha/liderancas` ganha chips com link (município) / território (`MapPinIcon`), edição por **Popover disparado por célula** com chips removíveis + combobox `Command` (busca por município/TI/ZE, lote de território/zona), gravação **por delta** direto em `leadership.municipalities` com lock `leadership-municipalities:{id}` (piso de 1 e teto de 30 respeitados) e `revalidatePath` (`liderancas`, `liderancas/[id]`, `municipios/[slug]` dos tocados); promove a lógica pura de `advisorMunicipalityPortfolio.ts`/`loadAdvisorMunicipalityIndex` (**B19 ✓**) para nomes neutros, sem portar a UI com `ResizeObserver`; **conflito registrado com o pedido literal** (pede um toggle "Editar" de tabela inteira, que B28/B31/B32 já travaram como anti-goal nesta mesma tabela — este plano segue o precedente e marca a decisão como _proposta, a validar com produto_); sem migration, [plano](plans/chips-municipios-lista-liderancas.md); ~~**B33**~~ ✓ ordenação e filtro no header da lista de dobradinhas (entregue 2026-07-26) — `?sort=`/`?dir=` por Nome/Partido + Popover multi-seleção de Partido com "Sem partido", consumindo o chrome compartilhado do **B21 ✓** (a corrida de ordem de chegada com B29 foi encerrada); paridade mobile (`StateDeputyFilters`) entrou por decisão desta sessão; ordem dos nulos de `party` pinada por int test; contagens derivadas (`municipalityCount`/`leadershipCount`) seguem sem sort/filtro, como travado, [plano](plans/ordenacao-filtros-lista-dobradinhas.md); **B32** editar Status de apoio na célula da lista de lideranças — clicar na célula abre um Popover com select de `supportStatus`, gravando sozinho por endpoint JSON dedicado (`POST /campanha/liderancas/support-status`, espelho do `expected-votes/`), **sem** o toggle "Editar" de tabela inteira (pedido explícito de produto, mesmo anti-goal do B28/B31); fecha o "Adiado com gatilho" que o próprio B29 já havia registrado para este caso; extrai `isSameOriginRequest` (2º endpoint) e as mensagens seguras do form action da ficha para módulos compartilhados; sem migration, [plano](plans/autosave-status-lista-liderancas.md); **B31** coluna de dobradinhas na lista de lideranças — chips com link + adicionar/remover na própria linha, com busca e gravação automática, na paridade pedida com a coluna "Municípios" de `/campanha/assessores` (B19 ✓) — paridade **da coluna**, não do modo "Editar" de tabela inteira (anti-goal do princípio 3 e do B28); escrita **por delta** com lock (`leadership-state-deputies:{id}`) em vez de replace do array, e `revalidatePath` porque `/campanha/dobradinhas` tem contagens derivadas; sem migration, [plano](plans/dobradinhas-lista-liderancas.md); **B30** convite de completar cadastro por WhatsApp na lista de lideranças — ação ao fim de cada linha de `/campanha/liderancas` reusando `createCampaignInvite`/`kind: 'autopreenchimento'` (mesmo fluxo do detalhe), disabled quando `contact.phone` não normaliza (`normalizeBrazilianPhone`); ícone distinto do WhatsApp de contato do B28 (soft, mesma linha); sem migration/action nova, [plano](plans/convite-whatsapp-lista-liderancas.md); **B29** ordenação e filtro no header da lista de lideranças — leva B15 ✓/B16 ✓ para `/campanha/liderancas` (sort/filtro na URL, popover no header, colunas "Setor" e "Última atualização"), ordenação por nome via join `contact.name` (sem desnormalizar), e é o **3º call site** que dispara a extração do par head/filtro para `shared/` (municípios migrado junto; B21 fecha seu adiado), [plano](plans/ordenacao-filtros-lista-liderancas.md); ~~**B28**~~ ✓ e-mail + celular na lista de `/campanha/liderancas` (entregue 2026-07-26) — colunas E-mail/Celular com copy-on-click + coluna de Ações com WhatsApp como em `/campanha/assessores` (B19 ✓); `CampaignCopyableCell` nasce compartilhado já no 2º call site (`AdvisorsTable` migrado no mesmo PR), `whatsAppHrefForPhone` promovido a `lib/phone.ts`; edição in-list adiada; sem migration, [plano](plans/email-celular-lista-liderancas.md); ~~**B27**~~ ✓ combobox + chips + auto-save no popover de Assessores de `/campanha/municipios` (entregue 2026-07-26), [plano](plans/combobox-assessores-lista-municipios.md); ~~**B26**~~ ✓ registrar sinal na célula "Último sinal" (entregue 2026-07-25), [plano](plans/registrar-sinal-lista-municipios.md); **fill-in** renomear coluna "Cobertura da meta" → "Cobertura" em `/campanha/municipios` — header curto (padrão A11); glossário/KPI strips intactos; B16 ✓ liberou o nome ao remover "Assessoria", [plano](plans/renomear-coluna-cobertura-meta.md); **B25** link do território na lista de municípios — a célula da coluna "Território" vira link para a linha daquele TI em `/campanha/territorios` (âncora, sem rota de detalhe); dep dura B21, [plano](plans/link-territorio-lista-municipios.md); ~~**B24**~~ ✓ auto-save + justificativa editável no popover de Tendência de `/campanha/municipios` (entregue 2026-07-26), [plano](plans/autosave-tendencia-lista-municipios.md); **fill-in** remover coluna "Tipo" de `/campanha/municipios` — município e ZE tratados iguais na coordenação; filtro por Salvador cobre as zonas; drop também do filtro/sort/`?kind=` na URL da lista, schema `kind` intacto, [plano](plans/remover-coluna-tipo-municipios.md); ~~**B23**~~ ✓ tooltip de conteúdo nas células das listas (entregue 2026-07-26), [plano](plans/tooltip-celulas-listas.md); **fill-in** opções selecionadas no topo nos Popovers de filtro do header — helper puro reutilizável pelas demais listas, [plano](plans/selecionados-no-topo-filtros-header.md); ~~**B22**~~ ✓ explicação por coluna no header das listas (entregue 2026-07-26), [plano](plans/explicacao-colunas-header-listas.md); **B21** página própria dos Territórios de Identidade — rota `/campanha/territorios` com overview + tabela no sistema de listas (sort/filtro no header), sem migration, [plano](plans/pagina-territorios-identidade.md); **B20 ✓** Municípios prioritários no Início entregue 2026-07-25 — atalho ranqueado por déficit + markup sem bullets, [plano](plans/municipios-prioritarios-inicio.md); **Pass 2 ✓** consolidação de engenharia — listas genéricas/B16+ absorvido/B17-B18 triviais/E10 limpo/C8 F4 ✓/D3 consent WhatsApp; **B16 ✓**; **E16 ✓**; **E9 ✓**; **E18 ✓**; **B19 ✓**; **E8 ✓**; C12 ✓; B18; B17; A11 ✓; B15 ✓; E4R ✓; E17 ✓; B14; janela 1: smoke, R6) Registro canônico dos **próximos** planos e débitos. Histórico de entregas: resumo abaixo + planos em [`docs/plans/`](plans/) + notebook [`.cursor/rules/projects/nucleos-eleitorais.mdc`](../.cursor/rules/projects/nucleos-eleitorais.mdc). ## Âncoras do calendário eleitoral 2026 (Res. TSE 23.760/2026) @@ -108,7 +108,7 @@ O discovery literatura→persona→entrevista ([relatório aprovado](research/re - **B29** ordenação e filtro no header da lista de lideranças — pedido de produto (2026-07-25): levar para `/campanha/liderancas` o que a lista de municípios ganhou em **B15 ✓** (ordenar clicando no header, `?sort=`/`?dir=` na URL) e **B16 ✓** (filtro multi-seleção em Popover no header, com busca, chips e "Limpar"). Hoje o estado da URL é só `q`+`page` e a ordem é fixa em `-updatedAt` — que, com o seed do **E4R ✓**, é a ordem em que a planilha gravou as fichas. Filtros v1 = **as colunas visíveis** (Status de apoio, Setor, Município com facet no escopo do ator, Acesso ao app), entrando as colunas "Setor" e "Última atualização" (ambas já no view model, hoje não renderizadas) para não haver ordenação invisível; ordenação **no banco**, incluindo nome por join `contact.name` (o adapter resolve caminho pontilhado em `buildOrderBy`) — **sem** desnormalizar nome na `leadership` (segunda fonte de verdade de pessoa é anti-goal do AGENTS; se o join não sair, corta-se o sort por nome, não a normalização). Corrige de passagem o teto mudo de `limit: 200` na pré-resolução de contatos da busca. É o **3º call site** do header rico (municípios ✓ · **B21** · este), o gatilho que o B21 registrou: extrai `CampaignSortableHead` + `CampaignHeaderFilterPopover` para `shared/` (só chrome — estado/optimistic/hrefs ficam nos wrappers de domínio) e migra municípios no mesmo PR; dá a B22/B23 um único lugar onde pousar. Sem migration/collection/Consent/action · ~1,5–2d · Janela 1–2 · Impeccable B · soft: B21 (quem entrar 2º duplica; B29 migra os três), B28 (mesma tabela — colunas de contato ficam sem head rico), B22/B23 (primitivo), B17/B18 (colunas já são dado; URL nasce salvável), E14 (níveis pousam aqui) · cortável · [plano](plans/ordenacao-filtros-lista-liderancas.md) - **B31** coluna de dobradinhas na lista de lideranças — pedido de produto (2026-07-25): em `/campanha/liderancas` adicionar a coluna **Dobradinhas** com as mesmas funcionalidades da coluna "Municípios" da tabela de assessores (**B19 ✓**): chips clicáveis (→ `/campanha/dobradinhas/`), remover chip, buscar e adicionar por typeahead, gravação automática otimista. O dado já chega e é descartado (`LeadershipRowViewModel.stateDeputyNames`, como `sector`/`phone` antes de B28/B29), e o **E4R ✓** semeou dobradinha só em `municipality.stateDeputies` — as fichas nasceram vazias e hoje só se preenchem abrindo uma a uma o `RelationMultiSelect` do detalhe e clicando "Salvar", exatamente o trabalho de mesa do onboarding. **Paridade é da coluna, não do modo:** o toggle "Editar" de tabela inteira dos assessores existe lá porque todas as colunas daquela tela são editáveis; trazê-lo para cá é o anti-goal literal do princípio 3 do `PRODUCT.md` (spreadsheet / inputs montados em toda linha) já travado no **B28** — a edição fica por célula (Popover, contrato de B9 ✓/B24/B26/B27; typeahead inline é troca de container no craft, não de contrato). Escrita **por delta** (`leadershipId`+`stateDeputyId`+`assigned`) em transação com lock `leadership-state-deputies:{id}` e teto de 20 — **não** o replace de array do `updateLeadershipInternal` (sob auto-save, dois atores na mesma ficha se sobrescrevem); ao contrário do B24/B27, aqui vale `revalidatePath` (25 linhas, e `/campanha/dobradinhas` tem `leadershipCount` derivado + a ficha do deputado lista lideranças). Catálogo entregue **uma vez por tabela** via provider cliente (props por célula multiplicariam o payload por linha). Access inalterado (`canManageLeadership` escreve, `canReadStateDeputy` lê); sem migration/collection/Consent/mudança de URL · ~1d · Janela 1–2 · Impeccable B · soft: B27 (mesma peça — chips + combobox + delta; extração para `shared/` só no 3º call site), B19 ✓ (padrão e espelho do cálculo), B28/B29/B30 (mesma tabela), B17 (a tabela chega a 8–10 colunas), E4R ✓ · cortável · [plano](plans/dobradinhas-lista-liderancas.md) - **B32** editar Status de apoio na célula da lista de lideranças — pedido de produto (2026-07-25): clicar na célula "Status" de `/campanha/liderancas` abre um Popover com `NativeSelect` de `supportStatus` (as 4 opções, mesmo Badge como trigger), gravando sozinho — **sem** botão "Salvar" e **sem** depender do toggle "Editar" de tabela inteira (`AdvisorsTable`/**B19 ✓**), anti-goal nomeado explicitamente no pedido e já travado no **B28**/**B31** para esta mesma tabela. O campo já é editável na ficha (`LeadershipInternalForm`) e o schema já trata `supportStatus` como opcional isolado (`leadershipInternalUpdateSchema`) — falta só o atalho pontual. Gravação por **endpoint JSON dedicado** (`POST /campanha/liderancas/support-status`, espelho do `expected-votes/`), reusando `updateLeadershipInternal` (`{ id, supportStatus }`, `overrideAccess: false`), **sem `revalidatePath`** (25 linhas, e um futuro sort/filtro por Status do B29 só reconcilia na próxima navegação, mesma escolha do B24). Fecha o "Adiado com gatilho" que o próprio **B29** já havia registrado para exatamente este caso. É o **2º endpoint JSON** da campanha — extrai `isSameOriginRequest` para `src/utilities/` (compartilhado com o futuro `expected-votes`/B24/B27) e as mensagens seguras do form action de `/liderancas/[id]` para um módulo dedicado. Sem migration/collection/Consent · ~0,4–0,5d · Janela 1 · Impeccable B · soft: B24 (mesma família de decisão — guard CSRF), B29 (gatilho fechado), B31 (mesma tabela, célula vizinha) · cortável · [plano](plans/autosave-status-lista-liderancas.md) -- **B33** ordenação e filtro no header da lista de dobradinhas — pedido de produto (2026-07-25): levar para `/campanha/dobradinhas` o que a lista de municípios ganhou em **B15 ✓** (ordenar clicando no header, `?sort=`/`?dir=` na URL) e **B16 ✓** (filtro em Popover no header, com busca, chips e "Limpar"), **sem duplicar código** — reusando a lógica e os componentes já construídos para essa família de listas. Hoje o estado da URL é só `q`+`page` e a ordem é fixa em `sort: 'name'` sem `dir` (`parseStateDeputyListParams`/`loadStateDeputyListPageData` em `src/utilities/stateDeputyData.ts`). V1 = ordenar por Nome (default) e Partido (campo indexado real) e filtrar por Partido (multi, facet dos valores presentes + "Sem partido"); `municipalityCount`/`leadershipCount` são contagens **derivadas** em memória (duas queries auxiliares), não campos de banco, então ficam fora do sort/filtro nesta entrega (agregado SQL seria outro item). **Zero segunda cópia do chrome de head/filtro:** este item consome `shared/CampaignSortableHead`/`CampaignHeaderFilterPopover` — a extração que o plano do **B29** já desenhou e cujo Não escopo nomeia explicitamente "`dobradinhas` … cada uma entra por pedido próprio"; se nenhum dos dois (B21/B29) tiver extraído ainda quando este item for implementado, a extração acontece aqui, seguindo o mesmo contrato desenhado no plano do B29 (mesma corrida de ordem de chegada). Módulo de URL próprio (`stateDeputyListUrl.ts`) e de filtros (`stateDeputyListFilters.ts`) espelhando `municipalityListUrl.ts`/`leadershipListUrl.ts` sobre `campaignListUrl.ts`, sem importar os outros domínios. Sem migration/collection/Consent/action de escrita · ~0,5–0,75 dia se o chrome compartilhado já existir, ~1–1,5 dia se este item extrair · Janela 1–2 · Impeccable B · soft: B29 (mesma corrida de extração), B21 (idem), B15 ✓/B16 ✓ (contrato a espelhar) · cortável · [plano](plans/ordenacao-filtros-lista-dobradinhas.md) +- ~~**B33** ordenação e filtro no header da lista de dobradinhas~~ — **entregue 2026-07-26** (`?sort=`/`?dir=` por Nome/Partido + Popover multi-seleção de Partido com "Sem partido", consumindo o chrome compartilhado do **B21 ✓** sem extrair de novo; `stateDeputyListUrl.ts`/`stateDeputyListFilters.ts` espelham `municipalityListUrl.ts`/`territoryListUrl.ts`; paridade mobile — `StateDeputyFilters` — entrou por decisão desta sessão; ordem dos nulos de `party` pinada por int test, não assumida; `municipalityCount`/`leadershipCount` seguem sem sort/filtro, como travado) · [plano](plans/ordenacao-filtros-lista-dobradinhas.md) - **B34** chips editáveis de municípios na lista de lideranças — pedido de produto (2026-07-25): em `/campanha/liderancas`, a coluna "Municípios" (hoje `row.municipalityNames.join(', ')`, texto morto sem link) ganha **chips**: cada município navega para `/campanha/municipios/[slug]`, um chip de território (`MapPinIcon`) aparece quando a liderança cobre o TI inteiro, e um ícone de edição ao fim da célula abre um **Popover** com os chips já atribuídos (removíveis) sobre um combobox `Command` com busca acento-insensível por município/território/ZE — adicionar/remover grava **na hora**, sem "Salvar", incluindo lote de território/ZE inteiro (paridade explicitamente pedida com `/campanha/assessores`, **B19 ✓**). **⚠️ Conflito registrado com o pedido literal:** o pedido pede um **botão de "modo de edição"** de tabela inteira, espelhando o toggle de `AdvisorsTable` — mas essa **mesma tabela** já travou a decisão oposta três vezes no mesmo dia (**B28**, **B31**, **B32**: "paridade é da coluna, não do modo... toggle Editar de tabela inteira é o anti-goal literal do princípio 3"). Este plano segue o precedente (Popover por célula, não toggle) e marca a decisão como _proposta — validar com produto_ antes de implementar. Diferença estrutural do B19: o vínculo mora no próprio `leadership.municipalities` (`hasMany`, piso de 1 via hook `requireAtLeastOneMunicipality`, teto de 30), não numa reverse relationship que pode ir a zero — mutação por **delta** direto no documento da liderança (não replace via `updateLeadershipInternalRecord`, que validaria o array inteiro contra o escopo do assessor e quebraria lideranças cross-boundary), lock único `leadership-municipalities:{id}` (ao contrário do B19, que trava por município), piso de 1 desabilitado no cliente + recusado no servidor como defesa em profundidade, sugestões de busca restritas ao escopo do assessor enquanto os chips já atribuídos usam o catálogo completo. Promove `advisorMunicipalityPortfolio.ts`/`loadAdvisorMunicipalityIndex` (**B19 ✓**) para nomes neutros (`municipalityPortfolio.ts`/`loadMunicipalityPortfolioIndex`) **sem** portar a UI de `AdvisorMunicipalityCell` (o `ResizeObserver` resolve um problema — empacotar chips numa célula sempre-visível — que não existe dentro de um Popover); catálogo entregue uma vez por tabela via provider cliente. `revalidatePath` (não endpoint JSON como o B27): a lista tem 25 linhas, não 435, e há derivados a jusante (`/campanha/liderancas/[id]`, e `/campanha/municipios/[slug]` via `MunicipalityLeadershipsPanel`/dossiê **E16 ✓**). Sem migration/collection/Consent/mudança de URL · ~1–1,25d · Janela 1–2 · Impeccable B · soft: B19 ✓ (lib de origem), B27 (mesma peça de célula), B31 (mesma tabela e mesma decisão de container — extração de editor de chips genérico só no 3º call site), B28/B29/B30/B32 (mesma tabela), B17 (tabela cresce), E16 ✓ (painel do município a revalidar) · cortável · [plano](plans/chips-municipios-lista-liderancas.md) - **B37** chips editáveis de municípios na lista de dobradinhas — pedido de produto (2026-07-25): em `/campanha/dobradinhas`, a coluna "Municípios" (hoje `row.municipalityCount`, um número sem nome) ganha o **mesmo tratamento** que a coluna homônima de `/campanha/assessores` (**B19 ✓**) e que o **B34** acabou de desenhar para `/campanha/liderancas`: chips com link para o município, chip de território (`MapPinIcon`) quando a dobradinha cobre o TI inteiro, e um ícone de edição na célula abrindo um **Popover** com os chips já atribuídos (removíveis) sobre um combobox `Command` (busca por município/TI/ZE, lote de território/zona) — gravação **na hora**, sem "Salvar". **⚠️ Mesmo conflito com o pedido literal já registrado quatro vezes no mesmo dia** (B28/B31/B32/B34): o pedido pede o toggle "Editar" de tabela inteira de `AdvisorsTable`; este plano segue o precedente e fica com o Popover por célula, marcado como _proposta — validar com produto_. Diferença estrutural do B34: aqui o vínculo mora do **lado errado** para reaproveitar aquele desenho — `municipality.stateDeputies` (reverse relationship, não `stateDeputy.municipalities`), sem piso mínimo (uma dobradinha pode não cobrir nenhum município ainda) e sem `assertMunicipalitiesWithinScope` (o próprio `canUpdateMunicipality` do Payload já escopa o assessor); escrita por **delta** no município tocado com lock **por município** (`municipality-state-deputies:{id}`, não por dobradinha), mesmo padrão do B19, reaproveitando o teto de 20 já existente em `stateDeputiesArraySchema`. Promove a mesma lib pura do B19/B34 (`advisorMunicipalityPortfolio.ts` → `municipalityPortfolio.ts`) — quem entre B34 e este chegar primeiro faz o `git mv`, o outro só importa; os três (B31+B34+este) juntos são o gatilho já registrado para extrair um `RelationshipChipEditor` agnóstico. `revalidatePath` (dobradinhas, `[slug]`, município tocado) — sem migration/collection/Consent/mudança de URL · ~0,75–1d · Janela 1–2 · Impeccable B · soft: B34 (mesma promoção de lib + mesma decisão de container), B31 (mesma família de pedido, outra tabela), B19 ✓ (origem da lib e do cálculo de próximo array), B33 (mesma tabela — ordenação/filtro no header), E4R ✓ (semeou dobradinha só do lado do município — este item é o preenchimento em lote que faltava) · cortável · [plano](plans/chips-municipios-lista-dobradinhas.md) - **B36** coluna de lideranças na lista de dobradinhas (espelho da coluna Municípios de Assessores) — pedido de produto (2026-07-25): em `/campanha/dobradinhas`, a coluna "Lideranças" (hoje `row.leadershipCount`, uma contagem sem nomes) ganha as **mesmas funcionalidades e UI** da coluna "Municípios" da tabela de assessores (**B19 ✓**): chips nomeados linkando para `/campanha/liderancas/[id]`, adicionar/remover na própria linha via Popover + busca (`Command`) sem botão "Salvar", gravação **por delta** com estado otimista, e clamp de 3 linhas + "Ver mais…" (`ResizeObserver`, portado do `AdvisorMunicipalityCell`) — necessário aqui porque, ao contrário do **B31** (a mesma relação vista do lado da liderança, onde poucos chips bastam), um deputado popular pode acumular dezenas de lideranças engajadas. É **a mesma aresta `leadership.stateDeputies`** que o B31 edita, só do lado oposto: o pedido explícito de **não duplicar lógica** é resolvido reusando **sem alteração** a mutação/schema/lock que o B31 desenha (`setLeadershipStateDeputyMembership`, teto `MAX_LEADERSHIP_STATE_DEPUTIES`=20, lock `leadership-state-deputies:{id}`) e promovendo a célula de chips do B31 para um único componente compartilhado bidirecional (`LeadershipStateDeputyRelationCell`, `direction: 'fromStateDeputy' | 'fromLeadership'`) em vez de um segundo componente-irmão fazendo a mesma coisa. Catálogo de busca novo (`loadLeadershipOptions`, espelha `loadStateDeputyOptions`) via `overrideAccess: false` (escopo do assessor já resolvido pelo access de `leadership`); `stateDeputyData.ts` troca `leadershipCount` por `leaderships: {id,name}[]` na mesma query batch já existente (mais `select`, não uma segunda consulta). Sem migration/collection/Consent/mudança de URL · ~0,5–0,75d se o B31 já tiver aterrissado (só a direção nova + o loader), ~1–1,5d se **B36** entrar primeiro (paga a extração da célula/action compartilhadas) · Janela 1–2 · Impeccable B · soft (corrida de extração): **B31** — quem entrar primeiro paga a peça compartilhada, o outro consome; B19 ✓ (padrão de origem), E4R ✓ (fichas vazias dos dois lados), B33 (mesma tabela) · cortável · [plano](plans/chips-liderancas-lista-dobradinhas.md) @@ -173,7 +173,7 @@ flowchart TD B30n["B30 Convite WhatsApp
na lista de lideranças"] B31n["B31 Coluna de dobradinhas
na lista de lideranças"] B32n["B32 Editar Status de apoio
na célula da lista"] - B33n["B33 Sort + filtro no header
da lista de dobradinhas"] + B33n["B33 ✓ Sort + filtro no header
da lista de dobradinhas"] B34n["B34 Chips editáveis de município
na lista de lideranças"] B37n["B37 Chips editáveis de município
na lista de dobradinhas"] B36n["B36 Chips de lideranças
na lista de dobradinhas"] @@ -318,7 +318,7 @@ Paralelizáveis agora: **B13** (E10 ✓ entregou as classes; consome `computeMun **Não cortáveis:** Onda 0 (jurídico/Consent); ~~**E4R** seed da planilha~~ (entregue 2026-07-24 — ainda aplicar em produção após smoke); ~~**E8** conta da cadeira~~ (entregue 2026-07-24); ~~**C12** registro-fundação~~ (entregue 2026-07-24); C2 dados reais; assimetria declarado×estimado (relação de campo); ~~**E9** fila de alocação~~ (entregue 2026-07-24 — era o mínimo de "inteligência, não planilha"); ~~**B19** gerenciar assessores~~ (entregue 2026-07-24 — CG/candidato não entram em `/admin`). -**Cortes seguros** (se o prazo apertar, nesta ordem): **D5** inbox→rascunhos (manter registro manual + `wa.me`); **D4** envio bridge (manter `wa.me`); **D3** fundação do canal (atalho `wa.me` continua); **E12** camada TI (rollup manual por lista; **E17** já dá a leitura regional básica no Início); **E13** planejador de giros (rebaixado na fila de corte em 2026-07-24: "perna"/agenda é a restrição dominante nomeada em campo — cortar só depois de E12; agenda segue manual com J-A/J-B como guia); **E15** backtest (pós-eleição por definição — cortar = perder o aprendizado 2030); **E11** motor v1 (manter a fila E9 ✓ sem sugestões); **B13** símbolo proporcional (manter quantis/LQ como escala); **E14** níveis (manter `priority` alta/normal); **B8 F2** polígonos (mapa continua agregado no município; manter F1 bairros); **D2** push (adiar); **A6**; **B14** município mais próximo (lista/busca e Recentes continuam); **B18** filtros salvos (lista + filtros manuais continuam; Visitados cobrem “voltar”); **B21** página dos Territórios de Identidade (o painel do E17 ✓ no Início continua entregando a leitura regional) — **B25** cai junto por definição (sem destino, sem link); **B17** seletor de colunas (tabela completa continua; scroll horizontal); ~~**B22** explicação no header~~ (entregue 2026-07-26); ~~**B23** tooltip nas células~~ (entregue 2026-07-26); ~~**B24** auto-save + justificativa na Tendência~~ (entregue 2026-07-26); **B27** combobox + chips nos Assessores (a lista de checkboxes com "Salvar" continua atribuindo — cortar custa a mesma coerência do B24 mais o tempo do CG procurando nomes numa coluna durante o onboarding; corte junto com B24, já que dividem o endpoint); **B28** e-mail + celular na lista de lideranças (a ficha `/liderancas/[id]` continua mostrando o contato — cortar custa o gesto de copiar/abrir ZAP na varredura; quick win barato de cortar e de fazer); **B30** convite de completar cadastro por WhatsApp na lista (o botão do detalhe continua gerando o convite; cortar custa só o atalho sem abrir a ficha — é o corte mais barato desta faixa, porque não toca nenhuma action/collection); **B31** coluna de dobradinhas nas lideranças (o `RelationMultiSelect` da ficha interna continua vinculando — cortar custa o preenchimento em lote do onboarding, uma ficha aberta por liderança, e mantém o vínculo invisível na varredura; corte junto com B27, já que dividem a peça de célula, e antes de B29 só se a mesa já tiver preenchido as dobradinhas à mão); **B32** editar Status de apoio na célula (a ficha `/liderancas/[id]` continua trocando o status pelo `NativeSelect` do formulário — cortar custa só o atalho de um clique a mais por linha; é o corte mais barato desta faixa, junto com B30, porque não toca nenhum contrato de dado); **B34** chips editáveis de municípios na lista de lideranças (o texto simples `row.municipalityNames.join(', ')` continua legível e a ficha `/liderancas/[id]` continua vinculando município via formulário — cortar custa o link direto ao município, a navegação por chip e o atalho de adicionar/remover sem abrir a ficha; corte junto com B31, já que dividem a mesma peça de célula editável, e antes de B29 pelo mesmo motivo de coerência); **B37** chips editáveis de municípios na lista de dobradinhas (o número `municipalityCount` continua legível e a ficha de município continua vinculando dobradinha pelo `MunicipalityStrategyForm` — cortar custa o link direto, a navegação por chip e o atalho de adicionar/remover sem abrir a ficha do município; corte junto com B34, já que dividem a mesma lib e a mesma decisão de container); **B36** coluna de lideranças na lista de dobradinhas (a contagem `leadershipCount` continua legível e a ficha `/liderancas/[id]` continua vinculando dobradinha via formulário — cortar custa o link direto à liderança, a navegação por chip e o atalho de adicionar/remover sem abrir a ficha; corte junto com B31, já que dividem a mesma mutação/schema/lock); **B33** sort + filtro no header das dobradinhas (a busca por nome e a ordem fixa por nome continuam funcionando — lista pequena hoje, cortar custa só o recorte por partido e a ordenação por partido; corta antes de B29 se o chrome compartilhado ainda não tiver sido pago por nenhum dos dois); **B29** sort + filtro no header das lideranças (a busca por nome continua funcionando e o escopo do assessor já reduz a lista — cortar custa o recorte por status/setor/município no onboarding da rede e mantém o header rico como cópia única de municípios; por ser o item mais caro desta faixa (~1,5–2 dias com a extração do head), cai **antes** do B26, que é o que sustenta o gate de adoção); **B26** registrar sinal na célula (o formulário completo em `/[slug]?tab=updates` continua sendo o caminho — cortar custa o laço da fila do E9 ✓ e o registro em campo pelo celular, que é justamente onde o gate de adoção se decide; por isso cai **depois** de B22/B23/B24); ~~**C13** renomear Plano de Ação → Atividade~~ (entregue 2026-07-25); **B20** prioritários no Início (lista `?priority=alta` continua; só perde o atalho — mas a seção atual mente, então cortar = conviver com o bug); fill-in **renomear coluna Cobertura** (a lista continua legível com o rótulo longo — corte = header mais largo); fill-in **remover coluna Tipo** (a lista continua completa; só mantém densidade morta — corte = conviver com a coluna); ~~**B16** filtros no header~~ (entregue 2026-07-25); ~~**B15** sort por coluna~~ (entregue 2026-07-24); ~~**A11**/**E17**~~ (entregues 2026-07-24); ~~**E18** documentação de conceitos~~ (entregue 2026-07-24); ~~**E16** dossiê~~ (entregue 2026-07-25); demais débitos/fill-ins. +**Cortes seguros** (se o prazo apertar, nesta ordem): **D5** inbox→rascunhos (manter registro manual + `wa.me`); **D4** envio bridge (manter `wa.me`); **D3** fundação do canal (atalho `wa.me` continua); **E12** camada TI (rollup manual por lista; **E17** já dá a leitura regional básica no Início); **E13** planejador de giros (rebaixado na fila de corte em 2026-07-24: "perna"/agenda é a restrição dominante nomeada em campo — cortar só depois de E12; agenda segue manual com J-A/J-B como guia); **E15** backtest (pós-eleição por definição — cortar = perder o aprendizado 2030); **E11** motor v1 (manter a fila E9 ✓ sem sugestões); **B13** símbolo proporcional (manter quantis/LQ como escala); **E14** níveis (manter `priority` alta/normal); **B8 F2** polígonos (mapa continua agregado no município; manter F1 bairros); **D2** push (adiar); **A6**; **B14** município mais próximo (lista/busca e Recentes continuam); **B18** filtros salvos (lista + filtros manuais continuam; Visitados cobrem “voltar”); **B21** página dos Territórios de Identidade (o painel do E17 ✓ no Início continua entregando a leitura regional) — **B25** cai junto por definição (sem destino, sem link); **B17** seletor de colunas (tabela completa continua; scroll horizontal); ~~**B22** explicação no header~~ (entregue 2026-07-26); ~~**B23** tooltip nas células~~ (entregue 2026-07-26); ~~**B24** auto-save + justificativa na Tendência~~ (entregue 2026-07-26); **B27** combobox + chips nos Assessores (a lista de checkboxes com "Salvar" continua atribuindo — cortar custa a mesma coerência do B24 mais o tempo do CG procurando nomes numa coluna durante o onboarding; corte junto com B24, já que dividem o endpoint); **B28** e-mail + celular na lista de lideranças (a ficha `/liderancas/[id]` continua mostrando o contato — cortar custa o gesto de copiar/abrir ZAP na varredura; quick win barato de cortar e de fazer); **B30** convite de completar cadastro por WhatsApp na lista (o botão do detalhe continua gerando o convite; cortar custa só o atalho sem abrir a ficha — é o corte mais barato desta faixa, porque não toca nenhuma action/collection); **B31** coluna de dobradinhas nas lideranças (o `RelationMultiSelect` da ficha interna continua vinculando — cortar custa o preenchimento em lote do onboarding, uma ficha aberta por liderança, e mantém o vínculo invisível na varredura; corte junto com B27, já que dividem a peça de célula, e antes de B29 só se a mesa já tiver preenchido as dobradinhas à mão); **B32** editar Status de apoio na célula (a ficha `/liderancas/[id]` continua trocando o status pelo `NativeSelect` do formulário — cortar custa só o atalho de um clique a mais por linha; é o corte mais barato desta faixa, junto com B30, porque não toca nenhum contrato de dado); **B34** chips editáveis de municípios na lista de lideranças (o texto simples `row.municipalityNames.join(', ')` continua legível e a ficha `/liderancas/[id]` continua vinculando município via formulário — cortar custa o link direto ao município, a navegação por chip e o atalho de adicionar/remover sem abrir a ficha; corte junto com B31, já que dividem a mesma peça de célula editável, e antes de B29 pelo mesmo motivo de coerência); **B37** chips editáveis de municípios na lista de dobradinhas (o número `municipalityCount` continua legível e a ficha de município continua vinculando dobradinha pelo `MunicipalityStrategyForm` — cortar custa o link direto, a navegação por chip e o atalho de adicionar/remover sem abrir a ficha do município; corte junto com B34, já que dividem a mesma lib e a mesma decisão de container); **B36** coluna de lideranças na lista de dobradinhas (a contagem `leadershipCount` continua legível e a ficha `/liderancas/[id]` continua vinculando dobradinha via formulário — cortar custa o link direto à liderança, a navegação por chip e o atalho de adicionar/remover sem abrir a ficha; corte junto com B31, já que dividem a mesma mutação/schema/lock); ~~**B33** sort + filtro no header das dobradinhas~~ (entregue 2026-07-26); **B29** sort + filtro no header das lideranças (a busca por nome continua funcionando e o escopo do assessor já reduz a lista — cortar custa o recorte por status/setor/município no onboarding da rede e mantém o header rico como cópia única de municípios; por ser o item mais caro desta faixa (~1,5–2 dias com a extração do head), cai **antes** do B26, que é o que sustenta o gate de adoção); **B26** registrar sinal na célula (o formulário completo em `/[slug]?tab=updates` continua sendo o caminho — cortar custa o laço da fila do E9 ✓ e o registro em campo pelo celular, que é justamente onde o gate de adoção se decide; por isso cai **depois** de B22/B23/B24); ~~**C13** renomear Plano de Ação → Atividade~~ (entregue 2026-07-25); **B20** prioritários no Início (lista `?priority=alta` continua; só perde o atalho — mas a seção atual mente, então cortar = conviver com o bug); fill-in **renomear coluna Cobertura** (a lista continua legível com o rótulo longo — corte = header mais largo); fill-in **remover coluna Tipo** (a lista continua completa; só mantém densidade morta — corte = conviver com a coluna); ~~**B16** filtros no header~~ (entregue 2026-07-25); ~~**B15** sort por coluna~~ (entregue 2026-07-24); ~~**A11**/**E17**~~ (entregues 2026-07-24); ~~**E18** documentação de conceitos~~ (entregue 2026-07-24); ~~**E16** dossiê~~ (entregue 2026-07-25); demais débitos/fill-ins. ## Bloqueadores atuais diff --git a/src/app/(campaign)/campanha/(app)/dobradinhas/page.tsx b/src/app/(campaign)/campanha/(app)/dobradinhas/page.tsx index bfa1114f..33aa1919 100644 --- a/src/app/(campaign)/campanha/(app)/dobradinhas/page.tsx +++ b/src/app/(campaign)/campanha/(app)/dobradinhas/page.tsx @@ -4,38 +4,90 @@ import Link from 'next/link' import { redirect } from 'next/navigation' import { getPayload } from 'payload' -import { CampaignListEmptyState } from '@/components/campaign/shared/CampaignListEmptyState' import { CampaignListFooter } from '@/components/campaign/shared/CampaignListFooter' import { CampaignListPendingBoundary, CampaignListResults, + CampaignTransitionAnchor, } from '@/components/campaign/shared/CampaignListPending' -import { CampaignSearchForm } from '@/components/campaign/shared/CampaignSearchForm' import { CampaignTable, CampaignTableHead, type CampaignTableColumn, } from '@/components/campaign/shared/CampaignTable' import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell' -import { Button } from '@/components/ui/button' +import { StateDeputyFilters } from '@/components/campaign/stateDeputy/StateDeputyFilters' +import { StateDeputySortableHead } from '@/components/campaign/stateDeputy/StateDeputySortableHead' +import { Button, buttonVariants } from '@/components/ui/button' +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from '@/components/ui/Empty' +import { cn } from '@/lib/utils' import { isCampaignStaff } from '@/utilities/campaignAccess' import { getCampaignUser } from '@/utilities/campaignAuth' import { - buildStateDeputyListHref, loadStateDeputyListPageData, - parseStateDeputyListParams, type StateDeputyRowViewModel, } from '@/utilities/stateDeputyData' +import { + clearStateDeputyListFilters, + type StateDeputyFilterOption, +} from '@/utilities/stateDeputyListFilters' +import { + buildStateDeputyListHref, + formatStateDeputyListSortSummary, + resolveStateDeputyListSort, + resolveStateDeputyListUrl, + type StateDeputyListState, +} from '@/utilities/stateDeputyListUrl' type StateDeputiesPageProps = { searchParams: Promise> } -const stateDeputyColumns: Array> = [ +const StateDeputyListEmptyState = ({ state }: { state: StateDeputyListState }) => ( + + + + + Nenhuma dobradinha encontrada + + Ajuste a busca ou os filtros para encontrar as dobradinhas. + + + + {/* Same contract as the filter bar's Limpar: drop filters, keep the sort. */} + + Limpar busca e filtros + + + +) + +const stateDeputyColumns = ( + state: StateDeputyListState, + partyFilterOptions: StateDeputyFilterOption[], + hasNoPartyOption: boolean, +): Array> => [ { id: 'name', mandatory: true, - head: Nome, + head: ( + + Nome + + ), cell: (row) => ( > = }, { id: 'party', - head: Partido, + head: ( + + Partido + + ), cellClassName: 'text-muted-foreground', cell: (row) => row.party ?? '—', }, @@ -67,12 +128,25 @@ const stateDeputyColumns: Array> = export default async function StateDeputiesPage({ searchParams }: StateDeputiesPageProps) { const rawSearchParams = await searchParams + const canonicalUrl = resolveStateDeputyListUrl(rawSearchParams) + if (canonicalUrl.redirectHref) redirect(canonicalUrl.redirectHref) + const [user, payload] = await Promise.all([getCampaignUser(), getPayload({ config })]) if (!user) redirect('/campanha/login') if (!isCampaignStaff(user)) redirect('/campanha') - const state = parseStateDeputyListParams(rawSearchParams) - const { rows, totalDocs, totalPages } = await loadStateDeputyListPageData(payload, user, state) + const { rows, totalDocs, totalPages, filterFacets } = await loadStateDeputyListPageData( + payload, + user, + canonicalUrl.state, + ) + const resolvedUrl = resolveStateDeputyListUrl(rawSearchParams, totalPages) + if (resolvedUrl.redirectHref) redirect(resolvedUrl.redirectHref) + const { state } = resolvedUrl + + const { sort, dir } = resolveStateDeputyListSort(state) + const sortSummary = formatStateDeputyListSortSummary(sort, dir) + const partyFilterOptions = filterFacets.parties.map((party) => ({ value: party, label: party })) return ( @@ -93,40 +167,33 @@ export default async function StateDeputiesPage({ searchParams }: StateDeputiesP - +

+ {sortSummary} +

+ row.id} + empty={} + /> {rows.length ? ( - <> - row.id} /> - buildStateDeputyListHref(state, page)} - /> - - ) : ( - - - - )} + buildStateDeputyListHref(state, page)} + /> + ) : null}
diff --git a/src/components/campaign/stateDeputy/StateDeputyFilters.tsx b/src/components/campaign/stateDeputy/StateDeputyFilters.tsx new file mode 100644 index 00000000..618c9714 --- /dev/null +++ b/src/components/campaign/stateDeputy/StateDeputyFilters.tsx @@ -0,0 +1,174 @@ +'use client' + +import { useRouter } from 'next/navigation' +import { useEffect, useRef, useState, useTransition } from 'react' + +import { useCampaignListPending } from '@/components/campaign/shared/CampaignListPending' +import { CampaignMobileMultiFilterField } from '@/components/campaign/shared/CampaignMobileMultiFilterField' +import { CampaignSearchInput } from '@/components/campaign/shared/CampaignSearchInput' +import { Button } from '@/components/ui/button' +import { Field, FieldLabel } from '@/components/ui/field' +import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select' +import { normalizedText } from '@/utilities/campaignListUrl' +import { + buildStateDeputyPartyOptions, + clearStateDeputyListFilters, + formatStateDeputyActiveFiltersSummary, + toggleStateDeputyPartyFilter, + type StateDeputyFilterOption, +} from '@/utilities/stateDeputyListFilters' +import { + buildStateDeputyListHref, + parseStateDeputyListParams, + parseStateDeputySortValue, + resolveStateDeputyListSort, + serializeStateDeputySortValue, + stateDeputyListSortOptions, + stateDeputyListStateToRawParams, + type StateDeputyListState, +} from '@/utilities/stateDeputyListUrl' + +const SEARCH_DEBOUNCE_MS = 1000 + +export const StateDeputyFilters = ({ + state, + partyOptions, + hasNoParty, +}: { + state: StateDeputyListState + /** Distinct party names present under the current search (server-computed facet). */ + partyOptions: StateDeputyFilterOption[] + /** Whether at least one facet-matching row has no party — gates the "Sem partido" option. */ + hasNoParty: boolean +}) => { + const router = useRouter() + const [search, setSearch] = useState(state.q ?? '') + const sharedPending = useCampaignListPending() + const [isLocalPending, startLocalTransition] = useTransition() + const isPending = sharedPending?.isPending ?? isLocalPending + const startTransition = sharedPending?.startTransition ?? startLocalTransition + const debounceRef = useRef | null>(null) + const { sort, dir } = resolveStateDeputyListSort(state) + const mobilePartyOptions = buildStateDeputyPartyOptions(partyOptions, hasNoParty) + const activeSummary = formatStateDeputyActiveFiltersSummary({ + ...state, + q: normalizedText(search) || state.q, + }) + + useEffect( + () => () => { + if (debounceRef.current) clearTimeout(debounceRef.current) + }, + [], + ) + + const clearDebounce = () => { + if (!debounceRef.current) return + clearTimeout(debounceRef.current) + debounceRef.current = null + } + + const navigateTo = (next: StateDeputyListState) => { + clearDebounce() + const canonical = parseStateDeputyListParams( + stateDeputyListStateToRawParams({ ...next, q: normalizedText(next.q), page: 1 }, 1), + ) + const nextHref = buildStateDeputyListHref(canonical, 1) + if (nextHref === buildStateDeputyListHref(state, 1)) return + startTransition(() => { + router.replace(nextHref, { scroll: false }) + }) + } + + const scheduleSearch = (value: string) => { + clearDebounce() + if (normalizedText(value) === state.q) return + debounceRef.current = setTimeout(() => { + debounceRef.current = null + navigateTo({ ...state, q: value, page: 1 }) + }, SEARCH_DEBOUNCE_MS) + } + + return ( +
{ + event.preventDefault() + navigateTo({ ...state, q: search, page: 1 }) + }} + > +

+ {isPending ? 'Atualizando resultados…' : ''} +

+ +
+ { + setSearch(event.target.value) + scheduleSearch(event.target.value) + }} + /> + {activeSummary ? ( +

+ {activeSummary} +

+ ) : null} + {activeSummary ? ( + + ) : null} +
+ +
+ {activeSummary ?

{activeSummary}

: null} + navigateTo(toggleStateDeputyPartyFilter(state, value))} + /> + + + Ordenar + { + const parsed = parseStateDeputySortValue(event.target.value) + if (parsed) navigateTo({ ...state, sort: parsed.key, dir: parsed.dir, page: 1 }) + }} + className="w-full [&_select]:h-11" + > + {stateDeputyListSortOptions.map((option) => ( + + {option.label} + + ))} + + +
+
+ ) +} diff --git a/src/components/campaign/stateDeputy/StateDeputyHeaderFilter.tsx b/src/components/campaign/stateDeputy/StateDeputyHeaderFilter.tsx new file mode 100644 index 00000000..1c8f057d --- /dev/null +++ b/src/components/campaign/stateDeputy/StateDeputyHeaderFilter.tsx @@ -0,0 +1,64 @@ +'use client' + +import { useOptimistic } from 'react' + +import { + CampaignHeaderFilterPopover, + type CampaignHeaderFilterRow, +} from '@/components/campaign/shared/CampaignHeaderFilterPopover' +import { + buildStateDeputyPartyOptions, + clearStateDeputyPartyFilter, + isStateDeputyPartyFilterActive, + toggleStateDeputyPartyFilter, + type StateDeputyFilterOption, +} from '@/utilities/stateDeputyListFilters' +import { buildStateDeputyListHref, type StateDeputyListState } from '@/utilities/stateDeputyListUrl' + +export const StateDeputyHeaderFilter = ({ + state, + options, + hasNoParty, +}: { + state: StateDeputyListState + /** Distinct party names present under the current search (server-computed facet). */ + options: StateDeputyFilterOption[] + /** Whether at least one facet-matching row has no party — gates the "Sem partido" row. */ + hasNoParty: boolean +}) => { + const [viewState, setOptimisticState] = useOptimistic(state) + const active = isStateDeputyPartyFilterActive(viewState) + + const allOptions = buildStateDeputyPartyOptions(options, hasNoParty) + + const rows: CampaignHeaderFilterRow[] = allOptions.map((option) => { + const next = toggleStateDeputyPartyFilter(viewState, option.value) + return { + value: option.value, + label: option.label, + href: buildStateDeputyListHref(next, 1), + selected: Boolean(viewState.parties?.includes(option.value)), + checkbox: true, + onChoose: () => setOptimisticState(next), + } + }) + + const clearedState = clearStateDeputyPartyFilter(viewState) + + return ( + setOptimisticState(clearedState), + } + : undefined + } + /> + ) +} diff --git a/src/components/campaign/stateDeputy/StateDeputySortableHead.tsx b/src/components/campaign/stateDeputy/StateDeputySortableHead.tsx new file mode 100644 index 00000000..ea124efe --- /dev/null +++ b/src/components/campaign/stateDeputy/StateDeputySortableHead.tsx @@ -0,0 +1,65 @@ +'use client' + +import type { ReactNode } from 'react' + +import { CampaignSortableHead } from '@/components/campaign/shared/CampaignSortableHead' +import { StateDeputyHeaderFilter } from '@/components/campaign/stateDeputy/StateDeputyHeaderFilter' +import type { StateDeputyFilterOption } from '@/utilities/stateDeputyListFilters' +import { + buildStateDeputySortHref, + defaultStateDeputyListSortDir, + resolveStateDeputyListSort, + stateDeputyListSortLabels, + type StateDeputyListSortKey, + type StateDeputyListState, +} from '@/utilities/stateDeputyListUrl' + +type StateDeputySortableHeadProps = { + state: StateDeputyListState + sortKey: StateDeputyListSortKey + children?: ReactNode + align?: 'left' | 'center' | 'right' + className?: string +} & + // Only one filterable column exists (Partido) — presence of `filterOptions` + // is the discriminant, unlike Territory's region|coverage `filterParam`. + (| { filterOptions: StateDeputyFilterOption[]; hasNoPartyOption: boolean } + | { filterOptions?: undefined; hasNoPartyOption?: undefined } + ) + +export const StateDeputySortableHead = ({ + state, + sortKey, + children, + align = 'left', + className, + filterOptions, + hasNoPartyOption, +}: StateDeputySortableHeadProps) => { + const { sort: activeSort, dir } = resolveStateDeputyListSort(state) + const active = activeSort === sortKey + const nextDir = active ? (dir === 'asc' ? 'desc' : 'asc') : defaultStateDeputyListSortDir() + + return ( + + ) : null + } + > + {children ?? stateDeputyListSortLabels[sortKey]} + + ) +} diff --git a/src/utilities/campaignListUrl.ts b/src/utilities/campaignListUrl.ts index d809988d..0cc1255d 100644 --- a/src/utilities/campaignListUrl.ts +++ b/src/utilities/campaignListUrl.ts @@ -30,6 +30,10 @@ export const parseExhaustiveEnumParam = ( return values.length < allowed.size ? values : [] } +/** "A, B" for ≤2 names, "A, B +N" beyond — the active-filters summary shorthand. */ +export const truncatedNamesLabel = (names: readonly string[]): string => + names.length <= 2 ? names.join(', ') : `${names.slice(0, 2).join(', ')} +${names.length - 2}` + export const normalizedText = (value: string | undefined): string | undefined => { if (typeof value !== 'string') return undefined const normalized = value.trim() diff --git a/src/utilities/municipalityListFilters.ts b/src/utilities/municipalityListFilters.ts index 95957ac9..de118f4f 100644 --- a/src/utilities/municipalityListFilters.ts +++ b/src/utilities/municipalityListFilters.ts @@ -5,6 +5,7 @@ */ import { isBahiaIdentityTerritory } from '@/lib/bahiaTerritories' import { getMunicipalityCatalogEntry } from '@/lib/municipalityCatalog' +import { truncatedNamesLabel } from '@/utilities/campaignListUrl' import { municipalityKindLabels, municipalityListCoverageLabels, @@ -310,10 +311,6 @@ export const isMunicipalityColumnFilterActive = ( } } -/** "Irecê, Recôncavo +3" — the summary never grows past two names per filter. */ -const firstNamesLabel = (names: string[]): string => - names.length <= 2 ? names.join(', ') : `${names.slice(0, 2).join(', ')} +${names.length - 2}` - export const formatMunicipalityActiveFiltersSummary = ( state: MunicipalityListState, ): string | null => { @@ -321,10 +318,12 @@ export const formatMunicipalityActiveFiltersSummary = ( if (state.priority) parts.push(municipalityPriorityLabels.alta) if (state.slugs?.length) { parts.push( - firstNamesLabel(state.slugs.map((slug) => getMunicipalityCatalogEntry(slug)?.name ?? slug)), + truncatedNamesLabel( + state.slugs.map((slug) => getMunicipalityCatalogEntry(slug)?.name ?? slug), + ), ) } - if (state.regions?.length) parts.push(firstNamesLabel([...state.regions])) + if (state.regions?.length) parts.push(truncatedNamesLabel([...state.regions])) if (state.kind) parts.push(municipalityKindLabels[state.kind]) if (state.advisors?.length) { parts.push(state.advisors.length === 1 ? '1 assessor' : `${state.advisors.length} assessores`) diff --git a/src/utilities/stateDeputyData.ts b/src/utilities/stateDeputyData.ts index 50e7ff16..5c403b34 100644 --- a/src/utilities/stateDeputyData.ts +++ b/src/utilities/stateDeputyData.ts @@ -3,16 +3,15 @@ import 'server-only' import type { Payload } from 'payload' import type { CampaignUser, Leadership, StateDeputy } from '@/payload-types' -import { - buildListHref, - firstValue, - normalizedText, - strictDecimalInteger, - type RawSearchParams, -} from '@/utilities/campaignListUrl' import { relationshipId } from '@/utilities/relationship' - -const stateDeputyPageSize = 25 +import { + buildStateDeputyListWhere, + NO_PARTY_FILTER_VALUE, + resolveStateDeputyListPayloadSort, + resolveStateDeputyListSort, + stateDeputyPageSize, + type StateDeputyListState, +} from '@/utilities/stateDeputyListUrl' export type StateDeputyRowViewModel = { id: number @@ -23,47 +22,75 @@ export type StateDeputyRowViewModel = { leadershipCount: number } -export type StateDeputyListState = { - page: number - q?: string +/** Values still reachable under the OTHER active filters (the Partido popover). */ +export type StateDeputyListFilterFacets = { + parties: string[] + hasNoParty: boolean } -export const parseStateDeputyListParams = (searchParams: RawSearchParams): StateDeputyListState => { - const q = normalizedText(firstValue(searchParams.q)) +/** + * Respects the search (an OTHER filter, from the popover's point of view) but + * drops the party filter itself — same contract as + * `loadMunicipalityListFilterFacets`: a selected value is unioned in so it + * stays visible to undo even if the search would otherwise hide it. + */ +const loadStateDeputyPartyFacet = async ( + payload: Payload, + user: CampaignUser, + state: StateDeputyListState, +): Promise => { + const result = await payload.find({ + collection: 'stateDeputy', + where: buildStateDeputyListWhere({ ...state, parties: undefined }), + depth: 0, + limit: 0, + pagination: false, + select: { party: true }, + user, + overrideAccess: false, + }) + + const availableParties = new Set( + (state.parties ?? []).filter((party) => party !== NO_PARTY_FILTER_VALUE), + ) + let hasNoParty = (state.parties ?? []).includes(NO_PARTY_FILTER_VALUE) + + for (const doc of result.docs) { + if (doc.party) availableParties.add(doc.party) + else hasNoParty = true + } + return { - page: strictDecimalInteger(firstValue(searchParams.page)) ?? 1, - ...(q ? { q } : {}), + parties: [...availableParties].sort((left, right) => left.localeCompare(right, 'pt-BR')), + hasNoParty, } } -const buildStateDeputyListSearchParams = ( - state: StateDeputyListState, - page = state.page, -): URLSearchParams => { - const params = new URLSearchParams() - if (state.q) params.set('q', state.q) - if (page > 1) params.set('page', String(page)) - return params -} - -export const buildStateDeputyListHref = (state: StateDeputyListState, page: number): string => - buildListHref(state, buildStateDeputyListSearchParams, '/campanha/dobradinhas', page) - export const loadStateDeputyListPageData = async ( payload: Payload, user: CampaignUser, state: StateDeputyListState, -): Promise<{ rows: StateDeputyRowViewModel[]; totalDocs: number; totalPages: number }> => { - const result = await payload.find({ - collection: 'stateDeputy', - where: state.q ? { name: { contains: state.q } } : {}, - depth: 0, - limit: stateDeputyPageSize, - page: state.page, - sort: 'name', - user, - overrideAccess: false, - }) +): Promise<{ + rows: StateDeputyRowViewModel[] + totalDocs: number + totalPages: number + filterFacets: StateDeputyListFilterFacets +}> => { + const { sort, dir } = resolveStateDeputyListSort(state) + const [result, filterFacets] = await Promise.all([ + payload.find({ + collection: 'stateDeputy', + where: buildStateDeputyListWhere(state), + depth: 0, + limit: stateDeputyPageSize, + page: state.page, + sort: resolveStateDeputyListPayloadSort(sort, dir), + select: { name: true, slug: true, party: true }, + user, + overrideAccess: false, + }), + loadStateDeputyPartyFacet(payload, user, state), + ]) const stateDeputyIDs = result.docs.map((doc) => doc.id) const municipalityCounts = new Map() @@ -121,6 +148,7 @@ export const loadStateDeputyListPageData = async ( })), totalDocs: result.totalDocs, totalPages: result.totalPages, + filterFacets, } } diff --git a/src/utilities/stateDeputyListFilters.ts b/src/utilities/stateDeputyListFilters.ts new file mode 100644 index 00000000..e3f67274 --- /dev/null +++ b/src/utilities/stateDeputyListFilters.ts @@ -0,0 +1,85 @@ +/** + * State deputy ("dobradinha") list filter affordances: Partido toggle, active + * filter summary and clear-filters. Mirrors `territoryListFilters.ts`; only + * one filterable column exists, so there is no B16+-style href fast path — + * `stateDeputy` runs in the dozens of rows, not 435. + */ +import { truncatedNamesLabel } from '@/utilities/campaignListUrl' +import { + NO_PARTY_FILTER_VALUE, + parseStateDeputyListParams, + stateDeputyListStateToRawParams, + type StateDeputyListState, +} from '@/utilities/stateDeputyListUrl' + +export type StateDeputyFilterOption = { + value: string + label: string +} + +const noPartyFilterLabel = 'Sem partido' + +/** + * Appends the "Sem partido" sentinel row to the facet-derived options — + * shared by the desktop popover (`StateDeputyHeaderFilter`) and the mobile + * filter bar (`StateDeputyFilters`) so the row is built once, not twice. + */ +export const buildStateDeputyPartyOptions = ( + options: readonly StateDeputyFilterOption[], + hasNoParty: boolean, +): StateDeputyFilterOption[] => + hasNoParty + ? [...options, { value: NO_PARTY_FILTER_VALUE, label: noPartyFilterLabel }] + : [...options] + +export const toggleStateDeputyPartyFilter = ( + state: StateDeputyListState, + value: string, +): StateDeputyListState => { + const current = state.parties ?? [] + const parties = current.includes(value) + ? current.filter((party) => party !== value) + : [...current, value] + return parseStateDeputyListParams({ + ...stateDeputyListStateToRawParams({ ...state, page: 1 }, 1), + party: parties, + }) +} + +/** Per-column clear (the header popover's "Limpar") — keeps the search and sort. */ +export const clearStateDeputyPartyFilter = (state: StateDeputyListState): StateDeputyListState => + parseStateDeputyListParams({ + ...stateDeputyListStateToRawParams({ ...state, page: 1 }, 1), + party: undefined, + }) + +/** + * The global "Limpar" contract shared by the mobile filter bar and the empty + * state: drop every filter and the search, keep the sort — same shape as + * `clearMunicipalityListFilters`. + */ +export const clearStateDeputyListFilters = (state: StateDeputyListState): StateDeputyListState => ({ + page: 1, + sort: state.sort, + dir: state.dir, +}) + +export const isStateDeputyPartyFilterActive = (state: StateDeputyListState): boolean => + Boolean(state.parties?.length) + +export const formatStateDeputyActiveFiltersSummary = ( + state: StateDeputyListState, +): string | null => { + const parts: string[] = [] + if (state.parties?.length) { + parts.push( + truncatedNamesLabel( + state.parties.map((party) => + party === NO_PARTY_FILTER_VALUE ? noPartyFilterLabel : party, + ), + ), + ) + } + if (state.q) parts.push(`Busca "${state.q}"`) + return parts.length ? parts.join(' · ') : null +} diff --git a/src/utilities/stateDeputyListUrl.ts b/src/utilities/stateDeputyListUrl.ts new file mode 100644 index 00000000..8700e58b --- /dev/null +++ b/src/utilities/stateDeputyListUrl.ts @@ -0,0 +1,231 @@ +/** + * State deputy ("dobradinha") list URL contract: state type, param + * parsing/canonicalization, Payload `where`, sort resolution and hrefs. + * Mirrors `municipalityListUrl.ts` (paginated, `resolveListUrl` clamp) and + * `territoryListUrl.ts` (lean sort-key/label shape) without importing either + * (B29's `leadershipListUrl.ts` doesn't exist yet — nothing to mirror there). + */ +import type { Where } from 'payload' + +import { + allParamValues, + buildListHref, + firstValue, + normalizedText, + resolveListUrl, + strictDecimalInteger, + type RawSearchParams, +} from '@/utilities/campaignListUrl' + +export const stateDeputyPageSize = 25 + +export type StateDeputyListSortKey = 'name' | 'party' +export type StateDeputyListSortDirection = 'asc' | 'desc' + +/** + * Sentinel for the "Sem partido" filter row — `party` is a free-text optional + * field (no closed enum to validate a real value against), so this constant + * is the only reserved token; a dobradinha whose actual party name collided + * with it would be indistinguishable, an accepted precedent-level risk (the + * curated field spans a couple dozen Brazilian party acronyms). + */ +export const NO_PARTY_FILTER_VALUE = 'sem_partido' + +export type StateDeputyListState = { + page: number + q?: string + /** Multi-select (OR) party names, plus the `NO_PARTY_FILTER_VALUE` sentinel. */ + parties?: string[] + sort?: StateDeputyListSortKey + dir?: StateDeputyListSortDirection +} + +export type StateDeputyListSearchParams = RawSearchParams + +const stateDeputyListParamNames = ['q', 'party', 'sort', 'dir', 'page'] as const +const stateDeputyListParamNameSet = new Set(stateDeputyListParamNames) + +const DEFAULT_STATE_DEPUTY_LIST_SORT_KEY: StateDeputyListSortKey = 'name' +const DEFAULT_STATE_DEPUTY_LIST_SORT_DIR: StateDeputyListSortDirection = 'asc' + +export const stateDeputyListSortLabels: Record = { + name: 'Nome', + party: 'Partido', +} + +const stateDeputyListSortKeySet = new Set(Object.keys(stateDeputyListSortLabels)) +const stateDeputyListSortDirSet = new Set(['asc', 'desc']) + +/** Both sort keys default to ascending (A–Z) — no "biggest first" semantics here. */ +export const defaultStateDeputyListSortDir = (): StateDeputyListSortDirection => + DEFAULT_STATE_DEPUTY_LIST_SORT_DIR + +export const resolveStateDeputyListSort = ( + state: StateDeputyListState, +): { sort: StateDeputyListSortKey; dir: StateDeputyListSortDirection } => { + const sort = state.sort ?? DEFAULT_STATE_DEPUTY_LIST_SORT_KEY + return { sort, dir: state.dir ?? defaultStateDeputyListSortDir() } +} + +/** `payload.find({ sort })` string — `-` prefix for descending. */ +export const resolveStateDeputyListPayloadSort = ( + sort: StateDeputyListSortKey, + dir: StateDeputyListSortDirection, +): string => (dir === 'desc' ? `-${sort}` : sort) + +export const formatStateDeputyListSortSummary = ( + sort: StateDeputyListSortKey, + dir: StateDeputyListSortDirection, +): string => { + const label = stateDeputyListSortLabels[sort] + return dir === 'asc' ? `Ordenado por ${label} (A–Z)` : `Ordenado por ${label} (Z–A)` +} + +export const stateDeputyListStateToRawParams = ( + state: StateDeputyListState, + page = state.page, +): StateDeputyListSearchParams => ({ + page: String(page), + q: state.q, + party: state.parties, + sort: state.sort, + dir: state.dir, +}) + +export const parseStateDeputyListParams = ( + params: StateDeputyListSearchParams, +): StateDeputyListState => { + const rawPage = strictDecimalInteger(firstValue(params.page)) + const q = normalizedText(firstValue(params.q)) + // `allParamValues` already trims and dedupes; only the length cap is ours. + const parties = allParamValues(params.party).filter((token) => token.length <= 32) + const rawSort = firstValue(params.sort) + const sort = + rawSort && stateDeputyListSortKeySet.has(rawSort) + ? (rawSort as StateDeputyListSortKey) + : undefined + const rawDir = firstValue(params.dir) + const dir = + rawDir && stateDeputyListSortDirSet.has(rawDir as StateDeputyListSortDirection) + ? (rawDir as StateDeputyListSortDirection) + : undefined + + return { + page: rawPage ?? 1, + ...(q ? { q } : {}), + ...(parties.length ? { parties } : {}), + ...(sort ? { sort } : {}), + ...(dir ? { dir } : {}), + } +} + +export const buildStateDeputyListWhere = (state: StateDeputyListState): Where => { + const filters: Where[] = [] + if (state.q) filters.push({ name: { contains: state.q } }) + + if (state.parties?.length) { + const namedParties = state.parties.filter((party) => party !== NO_PARTY_FILTER_VALUE) + const includesNoParty = state.parties.includes(NO_PARTY_FILTER_VALUE) + const partyFilters: Where[] = [] + if (namedParties.length) partyFilters.push({ party: { in: namedParties } }) + if (includesNoParty) partyFilters.push({ party: { exists: false } }) + // Every entry in a non-empty `parties` is named or the sentinel (or both), + // so `partyFilters` always has at least one member here. + filters.push(partyFilters.length > 1 ? { or: partyFilters } : partyFilters[0]) + } + + return filters.length ? { and: filters } : {} +} + +/** + * Serializes a state that is ALREADY canonical (came out of + * `parseStateDeputyListParams`, or was derived from a canonical state by a + * rule-preserving toggle) — same private-ish contract as + * `serializeCanonicalMunicipalityListSearchParams`. + */ +export const serializeCanonicalStateDeputyListSearchParams = ( + canonicalState: StateDeputyListState, +): URLSearchParams => { + const params = new URLSearchParams() + const resolvedSort = canonicalState.sort ?? DEFAULT_STATE_DEPUTY_LIST_SORT_KEY + const resolvedDir = canonicalState.dir ?? defaultStateDeputyListSortDir() + const isSortDefault = + resolvedSort === DEFAULT_STATE_DEPUTY_LIST_SORT_KEY && + resolvedDir === DEFAULT_STATE_DEPUTY_LIST_SORT_DIR + + if (canonicalState.q) params.set('q', canonicalState.q) + for (const party of canonicalState.parties ?? []) params.append('party', party) + if (!isSortDefault) { + params.set('sort', resolvedSort) + if (resolvedDir !== DEFAULT_STATE_DEPUTY_LIST_SORT_DIR) params.set('dir', resolvedDir) + } + if (canonicalState.page > 1) params.set('page', String(canonicalState.page)) + + return params +} + +const buildStateDeputyListSearchParams = ( + state: StateDeputyListState, + page = state.page, +): URLSearchParams => + serializeCanonicalStateDeputyListSearchParams( + parseStateDeputyListParams(stateDeputyListStateToRawParams(state, page)), + ) + +export const buildStateDeputyListHref = (state: StateDeputyListState, page: number): string => + buildListHref(state, buildStateDeputyListSearchParams, '/campanha/dobradinhas', page) + +export const buildStateDeputySortHref = ( + state: StateDeputyListState, + nextKey: StateDeputyListSortKey, +): string => { + const current = resolveStateDeputyListSort(state) + const dir = + current.sort === nextKey + ? current.dir === 'asc' + ? 'desc' + : 'asc' + : DEFAULT_STATE_DEPUTY_LIST_SORT_DIR + + return buildStateDeputyListHref({ ...state, sort: nextKey, dir, page: 1 }, 1) +} + +const sortOptionLabel = (key: StateDeputyListSortKey, dir: StateDeputyListSortDirection): string => + `${stateDeputyListSortLabels[key]} (${dir === 'asc' ? 'A–Z' : 'Z–A'})` + +export const stateDeputyListSortOptions = ( + Object.keys(stateDeputyListSortLabels) as StateDeputyListSortKey[] +).flatMap((key) => + (['asc', 'desc'] as const).map((dir) => ({ key, dir, label: sortOptionLabel(key, dir) })), +) + +export const serializeStateDeputySortValue = ( + key: StateDeputyListSortKey, + dir: StateDeputyListSortDirection, +): string => `${key}|${dir}` + +export const parseStateDeputySortValue = ( + value: string, +): { key: StateDeputyListSortKey; dir: StateDeputyListSortDirection } | null => { + const [key, dir] = value.split('|') + if (!stateDeputyListSortKeySet.has(key)) return null + if (!stateDeputyListSortDirSet.has(dir as StateDeputyListSortDirection)) return null + return { key: key as StateDeputyListSortKey, dir: dir as StateDeputyListSortDirection } +} + +export const resolveStateDeputyListUrl = ( + params: StateDeputyListSearchParams, + totalPages?: number, +): { + state: StateDeputyListState + href: string + redirectHref?: string +} => + resolveListUrl({ + params, + paramNameSet: stateDeputyListParamNameSet, + parse: parseStateDeputyListParams, + buildSearchParams: buildStateDeputyListSearchParams, + basePath: '/campanha/dobradinhas', + totalPages, + }) diff --git a/src/utilities/territoryListFilters.ts b/src/utilities/territoryListFilters.ts index 16cb0a90..a482f436 100644 --- a/src/utilities/territoryListFilters.ts +++ b/src/utilities/territoryListFilters.ts @@ -1,3 +1,4 @@ +import { truncatedNamesLabel } from '@/utilities/campaignListUrl' import { parseTerritoryListParams, territoryListStateToRawParams, @@ -54,12 +55,9 @@ export const isTerritoryFilterActive = ( param: TerritoryFilterParam, ): boolean => (param === 'region' ? Boolean(state.regions?.length) : Boolean(state.coverage)) -const firstTerritoryNamesLabel = (names: readonly string[]): string => - names.length <= 2 ? names.join(', ') : `${names.slice(0, 2).join(', ')} +${names.length - 2}` - export const formatTerritoryActiveFiltersSummary = (state: TerritoryListState): string | null => { const parts: string[] = [] - if (state.regions?.length) parts.push(firstTerritoryNamesLabel(state.regions)) + if (state.regions?.length) parts.push(truncatedNamesLabel(state.regions)) if (state.coverage) parts.push(territoryCoverageLabels[state.coverage]) if (state.q) parts.push(`Busca "${state.q}"`) return parts.length ? parts.join(' · ') : null diff --git a/tests/int/campaignEntityListData.int.spec.ts b/tests/int/campaignEntityListData.int.spec.ts index 3ea70623..37e5211f 100644 --- a/tests/int/campaignEntityListData.int.spec.ts +++ b/tests/int/campaignEntityListData.int.spec.ts @@ -15,6 +15,7 @@ import { loadDemandListPageData } from '@/utilities/campaignDemandData' import { loadLeadershipListPageData } from '@/utilities/leadershipData' import { loadOrganizationListPageData } from '@/utilities/organizationData' import { loadStateDeputyListPageData } from '@/utilities/stateDeputyData' +import { NO_PARTY_FILTER_VALUE } from '@/utilities/stateDeputyListUrl' import { installCampaignFixtures } from '../helpers/campaignFixtures' @@ -134,6 +135,85 @@ describe('loadStateDeputyListPageData', () => { leadershipCount: 0, }) }) + + it('sorts by party in both directions, pinning where the party-less row falls', async () => { + const fixtures = campaignFixtures() + const coordinator = await fixtures.createCampaignUser('coordinator') + const marker = fixtures.value('sort') + const withPsd = await fixtures.createStateDeputy({ + name: `Dep ${marker} PSD`, + party: 'PSD', + }) + const withPt = await fixtures.createStateDeputy({ name: `Dep ${marker} PT`, party: 'PT' }) + const withoutParty = await fixtures.createStateDeputy({ name: `Dep ${marker} sem` }) + + // Pinned adapter behavior (Postgres default, unmodified by Payload): ASC + // sorts nulls LAST, DESC sorts nulls FIRST — the party-less row never + // lands in the middle. "Sem partido" is the direct, unambiguous path to + // find it either way. + const asc = await loadStateDeputyListPageData(payload, coordinator, { + page: 1, + q: marker, + sort: 'party', + dir: 'asc', + }) + expect(asc.rows.map((row) => row.slug)).toEqual([withPsd.slug, withPt.slug, withoutParty.slug]) + + const desc = await loadStateDeputyListPageData(payload, coordinator, { + page: 1, + q: marker, + sort: 'party', + dir: 'desc', + }) + expect(desc.rows.map((row) => row.slug)).toEqual([withoutParty.slug, withPt.slug, withPsd.slug]) + }) + + it('filters by selected parties and by the "Sem partido" sentinel', async () => { + const fixtures = campaignFixtures() + const coordinator = await fixtures.createCampaignUser('coordinator') + const marker = fixtures.value('filter') + const withPt = await fixtures.createStateDeputy({ name: `Dep ${marker} PT`, party: 'PT' }) + await fixtures.createStateDeputy({ name: `Dep ${marker} PSD`, party: 'PSD' }) + const withoutParty = await fixtures.createStateDeputy({ name: `Dep ${marker} sem` }) + + const byParty = await loadStateDeputyListPageData(payload, coordinator, { + page: 1, + q: marker, + parties: ['PT'], + }) + expect(byParty.rows.map((row) => row.slug)).toEqual([withPt.slug]) + + const byNoParty = await loadStateDeputyListPageData(payload, coordinator, { + page: 1, + q: marker, + parties: [NO_PARTY_FILTER_VALUE], + }) + expect(byNoParty.rows.map((row) => row.slug)).toEqual([withoutParty.slug]) + }) + + it('computes the party facet from the current search, ignoring the party filter itself', async () => { + const fixtures = campaignFixtures() + const coordinator = await fixtures.createCampaignUser('coordinator') + const marker = fixtures.value('facet') + await fixtures.createStateDeputy({ name: `Dep ${marker} PT`, party: 'PT' }) + await fixtures.createStateDeputy({ name: `Dep ${marker} PSD`, party: 'PSD' }) + await fixtures.createStateDeputy({ name: `Dep ${marker} sem` }) + + const unfiltered = await loadStateDeputyListPageData(payload, coordinator, { + page: 1, + q: marker, + }) + expect(unfiltered.filterFacets).toEqual({ parties: ['PSD', 'PT'], hasNoParty: true }) + + // Selecting PT keeps every party option reachable — the facet ignores its + // own popover's filter so the other options remain visible to switch to. + const filteredByPt = await loadStateDeputyListPageData(payload, coordinator, { + page: 1, + q: marker, + parties: ['PT'], + }) + expect(filteredByPt.filterFacets).toEqual({ parties: ['PSD', 'PT'], hasNoParty: true }) + }) }) describe('loadDemandListPageData', () => { diff --git a/tests/unit/campaignEntityListParsers.unit.spec.ts b/tests/unit/campaignEntityListParsers.unit.spec.ts index a7aa1b04..fdec9cdf 100644 --- a/tests/unit/campaignEntityListParsers.unit.spec.ts +++ b/tests/unit/campaignEntityListParsers.unit.spec.ts @@ -1,7 +1,10 @@ /** - * Pins the five "simple" entity list parsers ahead of the Pass 2 list-system - * consolidation (W1): lideranças, organizações, dobradinhas, demandas and - * assessores. Their URL contracts must survive the migration byte-identical. + * Pins the "simple" entity list parsers ahead of the Pass 2 list-system + * consolidation (W1): lideranças, organizações, demandas and assessores. + * Their URL contracts must survive the migration byte-identical. Dobradinhas + * left this group in B33 — its parser grew a full sort/filter contract of its + * own and moved to `stateDeputyListUrl.ts`, pinned in + * `stateDeputyListUrl.unit.spec.ts`. */ import { describe, expect, it } from 'vitest' @@ -9,11 +12,9 @@ import { advisorListHrefForPage, parseAdvisorListParams } from '@/utilities/advi import { parseDemandListParams } from '@/utilities/campaignDemandData' import { parseLeadershipListParams } from '@/utilities/leadershipData' import { parseOrganizationListParams } from '@/utilities/organizationData' -import { parseStateDeputyListParams } from '@/utilities/stateDeputyData' const qAndPageParsers = [ ['leadership', parseLeadershipListParams], - ['stateDeputy', parseStateDeputyListParams], ['advisor', parseAdvisorListParams], ] as const diff --git a/tests/unit/stateDeputyListUrl.unit.spec.ts b/tests/unit/stateDeputyListUrl.unit.spec.ts new file mode 100644 index 00000000..eea785d3 --- /dev/null +++ b/tests/unit/stateDeputyListUrl.unit.spec.ts @@ -0,0 +1,152 @@ +import { describe, expect, it } from 'vitest' + +import { + clearStateDeputyListFilters, + clearStateDeputyPartyFilter, + formatStateDeputyActiveFiltersSummary, + toggleStateDeputyPartyFilter, +} from '@/utilities/stateDeputyListFilters' +import { + buildStateDeputyListHref, + buildStateDeputySortHref, + NO_PARTY_FILTER_VALUE, + parseStateDeputyListParams, + parseStateDeputySortValue, + resolveStateDeputyListPayloadSort, + resolveStateDeputyListSort, + resolveStateDeputyListUrl, + serializeCanonicalStateDeputyListSearchParams, + serializeStateDeputySortValue, +} from '@/utilities/stateDeputyListUrl' + +describe('state deputy list URL contract', () => { + it('defaults to page 1 with no sort/filters', () => { + expect(parseStateDeputyListParams({})).toEqual({ page: 1 }) + }) + + it('validates page as a positive decimal integer', () => { + expect(parseStateDeputyListParams({ page: '4' }).page).toBe(4) + expect(parseStateDeputyListParams({ page: '0' }).page).toBe(1) + expect(parseStateDeputyListParams({ page: '-2' }).page).toBe(1) + expect(parseStateDeputyListParams({ page: 'abc' }).page).toBe(1) + }) + + it('trims q, omits it when empty and takes the first repeated cell', () => { + expect(parseStateDeputyListParams({ q: ' ana ' }).q).toBe('ana') + expect(parseStateDeputyListParams({ q: ' ' }).q).toBeUndefined() + expect(parseStateDeputyListParams({ q: ['primeiro', 'segundo'] }).q).toBe('primeiro') + }) + + it('parses and dedupes repeated party params, ignoring overlong tokens', () => { + expect( + parseStateDeputyListParams({ + party: ['PT', 'PSD', 'PT', 'a'.repeat(33)], + }).parties, + ).toEqual(['PT', 'PSD']) + }) + + it('keeps only known sort keys and directions', () => { + expect(parseStateDeputyListParams({ sort: 'party', dir: 'desc' })).toEqual({ + page: 1, + sort: 'party', + dir: 'desc', + }) + expect(parseStateDeputyListParams({ sort: 'unknown', dir: 'sideways' })).toEqual({ page: 1 }) + }) + + it('omits the default sort pair from the canonical query', () => { + const state = parseStateDeputyListParams({ sort: 'name', dir: 'asc' }) + expect(serializeCanonicalStateDeputyListSearchParams(state).toString()).toBe('') + expect(resolveStateDeputyListSort(state)).toEqual({ sort: 'name', dir: 'asc' }) + }) + + it('serializes a non-default sort pair and repeated party filters', () => { + const state = parseStateDeputyListParams({ + q: 'edu', + party: ['PT', 'PSD'], + sort: 'party', + dir: 'desc', + }) + expect(buildStateDeputyListHref(state, 1)).toBe( + '/campanha/dobradinhas?q=edu&party=PT&party=PSD&sort=party&dir=desc', + ) + }) + + it('preserves filters while toggling sort direction and resets to page 1', () => { + const state = parseStateDeputyListParams({ q: 'edu', page: '3' }) + + expect(buildStateDeputySortHref(state, 'party')).toBe('/campanha/dobradinhas?q=edu&sort=party') + expect(buildStateDeputySortHref({ ...state, sort: 'party' }, 'party')).toContain('dir=desc') + }) + + it('resolves the payload sort string with the descending prefix', () => { + expect(resolveStateDeputyListPayloadSort('name', 'asc')).toBe('name') + expect(resolveStateDeputyListPayloadSort('party', 'desc')).toBe('-party') + }) + + it('clamps an out-of-range page and redirects non-canonical query strings', () => { + expect(resolveStateDeputyListUrl({ page: '9' }, 2)).toEqual({ + state: { page: 2 }, + href: '/campanha/dobradinhas?page=2', + redirectHref: '/campanha/dobradinhas?page=2', + }) + expect(resolveStateDeputyListUrl({ q: ' edu ', unknown: 'x' })).toEqual({ + state: { page: 1, q: 'edu' }, + href: '/campanha/dobradinhas?q=edu', + redirectHref: '/campanha/dobradinhas?q=edu', + }) + }) + + it('round-trips the mobile sort select value', () => { + expect(serializeStateDeputySortValue('party', 'desc')).toBe('party|desc') + expect(parseStateDeputySortValue('party|desc')).toEqual({ key: 'party', dir: 'desc' }) + expect(parseStateDeputySortValue('unknown|desc')).toBeNull() + expect(parseStateDeputySortValue('party|sideways')).toBeNull() + }) +}) + +describe('state deputy list filter state', () => { + it('toggles the party filter and resets the page', () => { + const withParty = toggleStateDeputyPartyFilter({ page: 3 }, 'PT') + expect(withParty).toEqual({ page: 1, parties: ['PT'] }) + expect(toggleStateDeputyPartyFilter(withParty, 'PT')).toEqual({ page: 1 }) + expect(toggleStateDeputyPartyFilter(withParty, NO_PARTY_FILTER_VALUE)).toEqual({ + page: 1, + parties: ['PT', NO_PARTY_FILTER_VALUE], + }) + }) + + it('clears only the party filter, keeping q and sort', () => { + expect( + clearStateDeputyPartyFilter({ + page: 2, + q: 'edu', + parties: ['PT'], + sort: 'party', + dir: 'desc', + }), + ).toEqual({ page: 1, q: 'edu', sort: 'party', dir: 'desc' }) + }) + + it('clears filters and search while preserving the selected sort', () => { + expect( + clearStateDeputyListFilters({ + page: 2, + q: 'edu', + parties: ['PT'], + sort: 'party', + dir: 'desc', + }), + ).toEqual({ page: 1, sort: 'party', dir: 'desc' }) + }) + + it('formats a compact active-filter summary, translating the sentinel', () => { + expect( + formatStateDeputyActiveFiltersSummary({ + page: 1, + q: 'edu', + parties: ['PT', 'PSD', NO_PARTY_FILTER_VALUE], + }), + ).toBe('PT, PSD +1 · Busca "edu"') + }) +}) From ca1a2081febf0b05e0a5c8e5530161e28d33a818 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Sun, 26 Jul 2026 16:27:37 -0300 Subject: [PATCH 2/2] =?UTF-8?q?docs(B33):=20registra=20d=C3=A9bito=20de=20?= =?UTF-8?q?engenharia=20do=20/simplify=20(B33+)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fecha a entrega do B33 registrando o achado do /simplify que sobrou maior que cleanup: StateDeputyFilters é o 3º call site do mesmo scaffold de navegação/debounce de TerritoryFilters/MunicipalityFilters. Novo fill-in B33+ (plano escala-dry-pos-b33.md) extrai useCampaignListFilterNavigation. Agregado SQL do facet de partido formalizado como Adiado com gatilho no plano-pai; referência cruzada obsoleta em escala-dry-pos-e10.md corrigida (party é texto livre, não enum fechado — o gatilho ali não disparou). --- .cursor/rules/projects/nucleos-eleitorais.mdc | 2 +- docs/plans/escala-dry-pos-b33.md | 85 +++++++++++++++++++ docs/plans/escala-dry-pos-e10.md | 2 +- .../ordenacao-filtros-lista-dobradinhas.md | 1 + docs/roadmap.md | 3 +- 5 files changed, 90 insertions(+), 3 deletions(-) create mode 100644 docs/plans/escala-dry-pos-b33.md diff --git a/.cursor/rules/projects/nucleos-eleitorais.mdc b/.cursor/rules/projects/nucleos-eleitorais.mdc index 85dc8a85..4cc928d8 100644 --- a/.cursor/rules/projects/nucleos-eleitorais.mdc +++ b/.cursor/rules/projects/nucleos-eleitorais.mdc @@ -17,7 +17,7 @@ alwaysApply: false **UI.** Legenda discreta (`MapScaleLegend`) com as faixas reais, do mais fraco para o mais forte, e a nota ligada ao seletor por `aria-describedby`; o readout **nunca** mostra a classe sozinha (research §6.4), soletra o porquê em texto real ("4ª de 5 faixas", "1,8× o padrão estadual do candidato", "12º entre 663 candidatos votados aqui"). Bolhas opcionais por válidos projetados (área ∝ valor, `interactive: false` para não empilhar dois alvos de hover no mesmo município, desenhadas da menor para a maior); **medido antes de otimizar** — 417 marcadores não derrubaram o frame, então o corte para top-K não foi feito. Dois conceitos novos em `/campanha/conceitos` (E18) com "Saiba mais" inline na nota; LQ já estava coberto por `dominancia-relativa`. Sem migration, sem collection, sem server action; o **First Load JS** de `/campanha` ficou byte a byte igual (9,46 kB / 265 kB) porque o painel do mapa já carrega em chunk próprio (`ssr: false`) — o chunk em si cresceu, e o payload RSC subiu ~64 KB não comprimidos (~10 KB gzip), quase tudo `competitiveRankByYear`. Gate verde (tsc, lint, format, knip, check:cycles, 467 unit / 387 int, build); Aikido sem achado. **Débito de teste registrado:** `campaignMunicipalities.e2e.spec.ts` é flaky com 2 workers **em `main` também** — verificado por stash: com as mudanças passa 5/5 isolado e falha na suíte cheia; sem as mudanças passou 15/15 numa rodada e falhou na seguinte. Duas causas visíveis: o checkbox de consentimento do `LeaderContactsPanel` clicado antes da hidratação ("Clicking the checkbox did not change its state") e colisão de regex quando o fixture sorteia "Conde" e o `getByRole` casa também "Condeúba". -- **B33 entregue em código (2026-07-26):** ordenação e filtro no header de `/campanha/dobradinhas` — `stateDeputyListUrl.ts`/`stateDeputyListFilters.ts` (contrato de URL/filtro próprio, sentinela `NO_PARTY_FILTER_VALUE` para "Sem partido") espelhando `municipalityListUrl.ts` (paginação/`resolveListUrl`) e `territoryListUrl.ts` (chaves/labels de sort) — não `leadershipListUrl.ts`, que o plano mandava espelhar mas **não existe** (B29 não entregou). `StateDeputySortableHead`/`StateDeputyHeaderFilter` consomem o chrome já compartilhado pelo **B21 ✓** sem extrair de novo (a corrida de ordem de chegada com B29 está encerrada); `loadStateDeputyPartyFacet` segue o contrato de faceta de `loadMunicipalityListFilterFacets` (respeita a busca, ignora o próprio filtro do popover). Ordem dos nulos de `party` **pinada por int test** em vez de assumida — Postgres/Payload ordena nulls por último em `asc` e primeiro em `desc`; "Sem partido" continua sendo o caminho direto via `party: { exists: false }`. Paridade mobile (`StateDeputyFilters`, espelho de `TerritoryFilters`) entrou por decisão desta sessão, fora do appetite original. Redirect canônico em duas fases (`resolveStateDeputyListUrl` → `totalPages` → resolve de novo) no molde de `municipios/page.tsx`. Sort v1 = Nome (default) e Partido; filtro v1 = Partido (multi + "Sem partido"); `municipalityCount`/`leadershipCount` seguem sem sort/filtro, como travado no plano. Gate verde (tsc, lint, format, check:cycles, 396 testes, build); Aikido 0 achados. **Correção pós-entrega no mesmo dia (design review independente):** mobile não tinha a opção "Sem partido" que o popover desktop já injetava (`hasNoParty` não chegava a `StateDeputyFilters`) e a tabela não passava `caption` ao `CampaignTable`, ao contrário dos dois precedentes citados no plano — ambos corrigidos no mesmo molde; copy do empty state também virou plural. **`/simplify` no mesmo dia:** a correção acima tinha copiado a montagem da linha "Sem partido" em vez de compartilhá-la — extraído `buildStateDeputyPartyOptions` em `stateDeputyListFilters.ts`, consumido por `StateDeputyFilters` e `StateDeputyHeaderFilter`; `StateDeputySortableHead` perdeu o discriminante `filterParam: 'party'` (só há uma coluna filtrável, `filterOptions` já basta); `parsePartiesParam` parou de reimplementar o dedupe de `allParamValues`; um `if` sempre-verdadeiro saiu de `buildStateDeputyListWhere`; `loadStateDeputyListPageData` ganhou `select` na query principal. Gate reverificado verde. **`/simplify` pós-`/rebase-on-main` (2ª rodada, mesmo dia):** rodou de novo sobre o commit squashado — removeu tipo+cast morto `StateDeputyPartyRow` (`stateDeputyData.ts`, `select` só afeta runtime, não a checagem estática), `stateDeputyColumns` (`page.tsx`) passou a importar `StateDeputyFilterOption` em vez de redeclarar a forma, `parsePartiesParam` foi inlineado (helper de uma linha usado uma vez). Achado maior: `firstPartyNamesLabel` era a 3ª cópia byte-idêntica da mesma função de truncar nomes (`firstTerritoryNamesLabel` em `territoryListFilters.ts`, `firstNamesLabel` em `municipalityListFilters.ts`) — cruzou o limiar de "3+ call sites"; extraído `truncatedNamesLabel` em `campaignListUrl.ts`, os três domínios migrados. Recomendação de hook compartilhado para o scaffold de busca debounced (`StateDeputyFilters`/`TerritoryFilters`/`MunicipalityFilters` reimplementam o mesmo ~50 linhas) registrada mas não perseguida — tocaria os três domínios, fora do escopo deste item. Gate reverificado verde (tsc, lint, format, check:cycles, 396 testes, build); Aikido 0 achados. Plano: [`ordenacao-filtros-lista-dobradinhas.md`](../../../docs/plans/ordenacao-filtros-lista-dobradinhas.md). +- **B33 entregue em código (2026-07-26):** ordenação e filtro no header de `/campanha/dobradinhas` — `stateDeputyListUrl.ts`/`stateDeputyListFilters.ts` (contrato de URL/filtro próprio, sentinela `NO_PARTY_FILTER_VALUE` para "Sem partido") espelhando `municipalityListUrl.ts` (paginação/`resolveListUrl`) e `territoryListUrl.ts` (chaves/labels de sort) — não `leadershipListUrl.ts`, que o plano mandava espelhar mas **não existe** (B29 não entregou). `StateDeputySortableHead`/`StateDeputyHeaderFilter` consomem o chrome já compartilhado pelo **B21 ✓** sem extrair de novo (a corrida de ordem de chegada com B29 está encerrada); `loadStateDeputyPartyFacet` segue o contrato de faceta de `loadMunicipalityListFilterFacets` (respeita a busca, ignora o próprio filtro do popover). Ordem dos nulos de `party` **pinada por int test** em vez de assumida — Postgres/Payload ordena nulls por último em `asc` e primeiro em `desc`; "Sem partido" continua sendo o caminho direto via `party: { exists: false }`. Paridade mobile (`StateDeputyFilters`, espelho de `TerritoryFilters`) entrou por decisão desta sessão, fora do appetite original. Redirect canônico em duas fases (`resolveStateDeputyListUrl` → `totalPages` → resolve de novo) no molde de `municipios/page.tsx`. Sort v1 = Nome (default) e Partido; filtro v1 = Partido (multi + "Sem partido"); `municipalityCount`/`leadershipCount` seguem sem sort/filtro, como travado no plano. Gate verde (tsc, lint, format, check:cycles, 396 testes, build); Aikido 0 achados. **Correção pós-entrega no mesmo dia (design review independente):** mobile não tinha a opção "Sem partido" que o popover desktop já injetava (`hasNoParty` não chegava a `StateDeputyFilters`) e a tabela não passava `caption` ao `CampaignTable`, ao contrário dos dois precedentes citados no plano — ambos corrigidos no mesmo molde; copy do empty state também virou plural. **`/simplify` no mesmo dia:** a correção acima tinha copiado a montagem da linha "Sem partido" em vez de compartilhá-la — extraído `buildStateDeputyPartyOptions` em `stateDeputyListFilters.ts`, consumido por `StateDeputyFilters` e `StateDeputyHeaderFilter`; `StateDeputySortableHead` perdeu o discriminante `filterParam: 'party'` (só há uma coluna filtrável, `filterOptions` já basta); `parsePartiesParam` parou de reimplementar o dedupe de `allParamValues`; um `if` sempre-verdadeiro saiu de `buildStateDeputyListWhere`; `loadStateDeputyListPageData` ganhou `select` na query principal. Gate reverificado verde. **`/simplify` pós-`/rebase-on-main` (2ª rodada, mesmo dia):** rodou de novo sobre o commit squashado — removeu tipo+cast morto `StateDeputyPartyRow` (`stateDeputyData.ts`, `select` só afeta runtime, não a checagem estática), `stateDeputyColumns` (`page.tsx`) passou a importar `StateDeputyFilterOption` em vez de redeclarar a forma, `parsePartiesParam` foi inlineado (helper de uma linha usado uma vez). Achado maior: `firstPartyNamesLabel` era a 3ª cópia byte-idêntica da mesma função de truncar nomes (`firstTerritoryNamesLabel` em `territoryListFilters.ts`, `firstNamesLabel` em `municipalityListFilters.ts`) — cruzou o limiar de "3+ call sites"; extraído `truncatedNamesLabel` em `campaignListUrl.ts`, os três domínios migrados. Recomendação de hook compartilhado para o scaffold de busca debounced (`StateDeputyFilters`/`TerritoryFilters`/`MunicipalityFilters` reimplementam o mesmo ~50 linhas) registrada mas não perseguida — tocaria os três domínios, fora do escopo deste item; capturada no fechamento como **B33+** (fill-in de engenharia, `useCampaignListFilterNavigation`, ~0,5d). Gate reverificado verde (tsc, lint, format, check:cycles, 396 testes, build); Aikido 0 achados. Agregado SQL para o facet de partido também adiado com gatilho (só compensa em escala). Plano: [`ordenacao-filtros-lista-dobradinhas.md`](../../../docs/plans/ordenacao-filtros-lista-dobradinhas.md) · fill-in: [`escala-dry-pos-b33.md`](../../../docs/plans/escala-dry-pos-b33.md). - **B27 entregue em código (2026-07-26):** combobox + chips + auto-save no popover de Assessores de `/campanha/municipios` — trocou a lista de checkboxes + botão "Salvar assessores" por chips removíveis (`Badge`+`XIcon`, `aria-label="Remover "`) acima de um `Command` (`shouldFilter={false}` + `matchesAtWordStart`, acento-insensível), com gravação **por delta** (um toggle por vez, não replace do array). Fase 1 — `nextAdvisorIdsAfterMembership` subiu de `actions/advisor.ts` para `src/lib/municipalityAdvisorMembership.ts` (compartilhado com a carteira do B19); `setMunicipalityAdvisorMembershipRecord`/`setMunicipalityAdvisorMembership` em `actions/municipality.ts` (mesma transação + lock `municipality-advisors:{id}` + `reloadUnrestrictedActor`, mas sem `assertTargetAdvisor` — o alvo pode ser coordenador/candidato, legal nesta tela). Fase 2 — endpoint `POST /campanha/municipios/advisors` espelhando `expected-votes/route.ts` (`isSameOriginRequest` local, zod, `mapCampaignFormActionError`). Fase 3 — `MunicipalityListAdvisorsControl.tsx` reescrito: estado otimista + contador de pendências (reconcilia só quando toda entrega em voo assentou, nunca aborta um delta anterior), `Alert` de erro, `aria-live` `sr-only`; polish do `/impeccable` alinhou o gatilho (`aria-expanded`/`aria-haspopup`/realce `bg-muted/60` ao abrir) ao padrão do `MunicipalityListExpectedVotesControl` irmão (B24). `assignMunicipalityAdvisorsRecord` (replace do array) sobrevive só para `/editar`. Achado de infraestrutura no caminho: o Playwright `webServer` (`pnpm dev`) recompila uma rota POST-only no primeiro hit e o Fast Refresh resultante aborta o fetch em voo do teste (`ERR_ABORTED`) — `tests/e2e/setup.e2e.spec.ts` ganhou um prewarm dummy de `/campanha/municipios/advisors` (e retroativamente de `expected-votes/`, mesmo padrão) antes dos specs reais. Gate verde: tsc/lint/format/`check:cycles` limpos, 393 unit+int (6 novos), e2e do fluxo (isolado e em suíte) verde — falhas observadas em rodadas full-suite eram flakiness de ambiente sob carga sustentada (testes não relacionados falhando de forma rotativa, sempre verdes isolados), não regressão do B27; build produção ok; Aikido 0 achados nos 9 arquivos novos/editados. Plano: [`combobox-assessores-lista-municipios.md`](../../../docs/plans/combobox-assessores-lista-municipios.md). - **B23 entregue em código (2026-07-26):** tooltip de conteúdo nas células das listas — a mecânica genérica (`cellTooltip` em `CampaignTableColumn`, `CampaignCellTooltip`, `CampaignHoverTooltip` promovido para `shared/`) já tinha saído de graça no **E10 ✓**; este item fechou o que faltava: `openOnTouch`/`disabled` em `CampaignHoverTooltip` (default `true`/`false`, sem mudança nos 4 call sites pré-B23) para conviver com um Popover no mesmo gatilho sem roubar o tap nem sobreviver atrás dele — e, no `/simplify`, ganhou um terceiro comportamento: `content` falsy renderiza o `children` sem wrapper, então os dois consumidores abaixo não precisam mais de um ternário `content ? … : children` cada; `formatAdvisorNamesTooltip` + `advisorEntriesFromIds` (dedupe do id→nome entre a lista e o controle) junto de `MunicipalityAdvisorAvatarStack.tsx`; e os dois consumidores de `/campanha/municipios` — célula "Assessores" (não-coordenador via `cellTooltip`; coordenador envolvendo o próprio `PopoverTrigger`, `openOnTouch={false}`/`disabled={open}`, nunca os dois ao mesmo tempo) e célula "Tendência" (tooltip só quando há `politicalTrendNote`; em toque a nota aparece como leitura dentro do próprio Popover, acima do select). Verificado manualmente em `/campanha/municipios` como coordenador e assessor: hover mostra nomes/justificativa, foco de teclado abre a mesma tooltip, clique/tap abre o Popover sem tooltip órfã atrás. Gate verde (tsc/lint/format/knip — P3 pré-existente/check:cycles/443 unit/387 int/build); Aikido 0 findings. Plano: [`tooltip-celulas-listas.md`](../../../docs/plans/tooltip-celulas-listas.md). - **B22 entregue em código (2026-07-26):** explicação por coluna no header de `/campanha/municipios` — `description?: ReactNode` em `CampaignTableHead` (`shared/CampaignTable.tsx`) e rename `tooltip`→`description` em `MunicipalitySortableHead`, ambos com o mesmo sublinhado pontilhado no label e o mesmo `CampaignHoverTooltip` compartilhado (já promovido para `shared/` pelo **E10 ✓**) — sem componente novo. Auditoria pré-implementação achou o escopo já reduzido: o primitivo de tooltip e o `cellTooltip` genérico já existiam (E10), então só faltava a prop em `CampaignTableHead` e o rename. Novo helper `campaignConceptOneLiner(id)` ao lado de `campaignConceptHref` em `campaignIntelligenceConcepts.ts` — fonte única para as colunas cobertas pelo **E18 ✓** (`expectedVotes` lê `oneLiner('meta')`, `goalCoverage` lê `oneLiner('cobertura-da-meta')` + a nota de cenário). Novo `MunicipalityListColumnId` + `municipalityColumnDescriptions` (`Record` de 11 entradas) em `municipalityLabels.ts` — a coluna "Classe" mantém o texto mais longo (`CLASS_COLUMN_DESCRIPTION`, movido de `MunicipalityList.tsx`) em vez do `oneLiner` terso de `classe-territorial`, decisão assumida porque uma classificação de 5 vias não cabe numa frase. `MunicipalityList.tsx` perdeu os três `*Hint` locais e a intersecção de tipo extra em `municipalityListColumns()`. Testes: pin do `campaignConceptOneLiner`, `describe('CampaignTableHead description')` (sem prop = render idêntico; com prop = 1 trigger, conteúdo fora do markup do servidor, mesmo contrato do `cellTooltip`), e contagem de `data-slot="tooltip-trigger"` no `` (10 staff / 5 leader) em `campaignComponents.unit.spec.ts`. Gate verde (tsc, lint, format, check:cycles, 442 unit / 387 int, build); `knip` segue com o erro pré-existente ao carregar `payload.config.ts` (ledgerado P3, confirmado idêntico fora desta mudança); Aikido sem achado. Fora do escopo (mantido do plano): copy nas outras 5 listas, tooltip nos cards mobile, variante Popover no touch. Plano: [`explicacao-colunas-header-listas.md`](../../../docs/plans/explicacao-colunas-header-listas.md). diff --git a/docs/plans/escala-dry-pos-b33.md b/docs/plans/escala-dry-pos-b33.md new file mode 100644 index 00000000..a719ace6 --- /dev/null +++ b/docs/plans/escala-dry-pos-b33.md @@ -0,0 +1,85 @@ +# Escala e DRY pós-B33 (ordenação e filtros de dobradinhas) + +Status: rascunho +Atualizado em: 2026-07-26 +Item do roadmap: [docs/roadmap.md](../roadmap.md) (Fill-ins abertos, **B33+**, fill-in de engenharia) +Impeccable: A — N/A (sem superfície UI nova; extrai comportamento já entregue sem mudar contrato de URL nem markup visível) +Appetite: ~0,5 dia eng; fase única, sem migration, sem collection, sem `Consent` +Responsável: — + +## Dados → decisão → apresentação + +- **Vou apresentar dados?** Não (N/A) — o lote é custo de manutenção de um hook de navegação já entregue três vezes; nenhuma métrica, série ou escala nova. +- **Anti-goals de dado:** N/A. + +## Contexto + +**B33** ([ordenacao-filtros-lista-dobradinhas.md](ordenacao-filtros-lista-dobradinhas.md), entregue 2026-07-26) adicionou paridade mobile (`StateDeputyFilters`) espelhando `TerritoryFilters` (B21 ✓) e `MunicipalityFilters` (B16 ✓). A 2ª passagem `/simplify` da entrega (revisor reuse, 2026-07-26) confirmou que as três já são o **3º call site** do mesmo scaffold de navegação: `SEARCH_DEBOUNCE_MS = 1000`, `useState` de busca, fallback `useCampaignListPending() ?? useTransition()` local, `debounceRef` com cleanup no `useEffect`, `clearDebounce`, `navigateTo` (compara o href canônico com o atual e só navega se mudou) e `scheduleSearch` (debounce que reusa `navigateTo`) — ~35–50 linhas idênticas ou quase idênticas em cada um dos três arquivos: + +- `src/components/campaign/stateDeputy/StateDeputyFilters.tsx:31–90` +- `src/components/campaign/municipality/TerritoryFilters.tsx:31–84` +- `src/components/campaign/municipality/MunicipalityFilters.tsx:35–96` + +A diferença entre os três está só em **como** o `next: State` vira `State` canônico e `href` — `StateDeputyFilters`/`TerritoryFilters` chamam `parseXListParams(xListStateToRawParams(...))` seguido de `buildXListHref`; `MunicipalityFilters` compara por `buildMunicipalityFiltersKey` em vez de recomputar o href duas vezes. O corpo do `
` (busca + resumo de filtros + botão "Limpar" + bloco mobile com `Field`/`NativeSelect` de ordenação) também se repete, mas os filtros do meio do bloco mobile (Partido/Território+Assessoria/4 multi-filtros) divergem por domínio — não faz parte deste lote. + +O E10 já havia identificado esse padrão previamente (ver "Adiado com gatilho" de [escala-dry-pos-e10.md](escala-dry-pos-e10.md)) para os multi-filtros do **bloco mobile interno**; este lote é sobre o **scaffold de navegação/debounce** ao redor deles, que é outro pedaço do mesmo arquivo. + +## Objetivos + +- Um único hook (`useCampaignListFilterNavigation`) concentra `search`/`setSearch`, `isPending`, `navigateTo` e `scheduleSearch`; os três componentes de filtro passam a chamá-lo em vez de reimplementar `debounceRef`/`useEffect`/`clearDebounce`. +- Comportamento visível idêntico: mesmo debounce de 1000ms, mesmo dimming via `useCampaignListPending`, mesmas URLs geradas — sem migration, sem mudança de contrato de URL. +- Guardrail: o hook não decide **o que** é canônico (isso continua em `campaignListUrl.ts` + módulo por domínio); ele só orquestra o ciclo debounce → canonicalizar → comparar → `router.replace` dentro de uma transition. + +## Decisões travadas + +- **O hook recebe callbacks (`toCanonical`, `toHref`), não os módulos de URL por domínio.** Assim ele não importa `stateDeputyListUrl`/`territoryListUrl`/`municipalityListUrl` e continua em `shared/` sem inverter a direção de dependência (`components/campaign/` → hook genérico, nunca o contrário). Fonte: `/simplify` B33 rodada 2 (2026-07-26), reuse review. **Rejeitado:** o hook aceitar o `State` genérico e um único `resolveListUrl` importado de `campaignListUrl.ts` — os três domínios já têm assinaturas de canonicalização levemente diferentes (`MunicipalityFilters` compara por `buildMunicipalityFiltersKey`, os outros dois recomputam href duas vezes), então forçar uma assinatura comum agora exigiria mudar `buildXListHref`/`parseXListParams` dos três módulos só para caber no hook — inverte o custo (a duplicação é mais barata que a unificação de assinatura). +- **Só o scaffold de navegação sai; o ``/JSX permanece por componente.** O corpo visual diverge o bastante (Partido vs Território+Assessoria vs 4 multi-filtros) que um componente de shell genérico viraria um `children`-render-prop só para economizar ~15 linhas de JSX repetido (input de busca + resumo + botão Limpar), o que é o tipo de abstração prematura que o `engineering-standards.mdc` pede para evitar. **Rejeitado:** `CampaignListFilterBar` com slot para os filtros do meio — analisado e descartado por esticar a interface para 3 usos ainda divergentes. +- **i18n e naming** seguem o AGENTS.md: identificador em inglês (`useCampaignListFilterNavigation`), sem strings visíveis novas. + +## Questões em aberto + +- **O hook expõe `scheduleSearch` só para o campo de busca, ou também um `commitNow` para os outros controles (select/popover) chamarem `navigateTo` direto?** **Opções:** (a) expor `navigateTo` cru + `scheduleSearch` (debounce) como duas funções separadas do hook; (b) só `scheduleSearch`, com os outros controles chamando `navigateTo` importado à parte. **Recomendação:** (a) — é exatamente o que os três componentes já fazem hoje (search usa `scheduleSearch`, sort/filtros usam `navigateTo` direto), então o hook só precisa expor os dois nomes que já existem, sem inventar terceiro. + +## Abordagem proposta + +```mermaid +flowchart LR + Hook["useCampaignListFilterNavigation
(shared/)"] --> SD["StateDeputyFilters"] + Hook --> TF["TerritoryFilters"] + Hook --> MF["MunicipalityFilters"] +``` + +Componentes: + +- **`useCampaignListFilterNavigation`** (novo, `src/components/campaign/shared/useCampaignListFilterNavigation.ts`): recebe `{ initialQuery, toCanonicalHref(next), isDebounced }`, devolve `{ search, setSearch, isPending, navigateTo(next), scheduleSearch(value, buildNext) }`. Internamente reusa `useCampaignListPending()` com fallback a `useTransition()` local (mesmo padrão dos três arquivos hoje) e o `debounceRef`/cleanup. +- **`StateDeputyFilters.tsx`** / **`TerritoryFilters.tsx`** / **`MunicipalityFilters.tsx`**: substituem os blocos duplicados (linhas citadas acima) pela chamada ao hook, passando o `toCanonicalHref` específico do domínio (que já existe: `parseXListParams` + `buildXListHref`, ou `buildMunicipalityFiltersKey`). JSX inalterado. +- **Migration**: sem migration, sem collection, sem server action. + +## Dependências + +- Nenhuma de outro plano aberto. Reusa `useCampaignListPending` (`shared/CampaignListPending.tsx`) já compartilhado pelos três. + +## Não escopo + +- **Unificar o ``/JSX dos três filtros** — analisado e rejeitado acima; cada domínio mantém seu próprio corpo mobile. +- **Multi-filtros do bloco mobile saindo de `*ListFilters.ts` definitions** — já é o F4 de [escala-dry-pos-e10.md](escala-dry-pos-e10.md); este lote não duplica esse escopo. +- **Extrair o par head/filtro do header desktop (`CampaignSortableHead`/`CampaignHeaderFilterPopover`)** — já compartilhado desde B21 ✓; fora deste lote, que é só o scaffold de busca/debounce. + +## Rabbit holes + +- **"Já que estou extraindo, generalizo `State` com um único módulo de URL parametrizado."** Se alguém "só completar": os três contratos de URL (`stateDeputyListUrl.ts`/`territoryListUrl.ts`/`municipalityListUrl.ts`) colapsam num DSL genérico, exatamente o anti-padrão que "Decisões travadas" de B33/B21/B29 já rejeitaram por acoplar três contratos independentes num só ponto de mudança. **Mitigação neste item:** o hook recebe callbacks, nunca importa os módulos de URL por domínio. + +## Adiado com gatilho + +Nenhum neste item. + +## Referências + +- `docs/roadmap.md` (Fill-ins abertos — **B33+**) +- [ordenacao-filtros-lista-dobradinhas.md](ordenacao-filtros-lista-dobradinhas.md) — o pai do lote (B33 ✓) +- [escala-dry-pos-e10.md](escala-dry-pos-e10.md) — F4, o débito irmão do bloco mobile interno (não sobreposto por este lote) +- `src/components/campaign/stateDeputy/StateDeputyFilters.tsx` (`:31`–`:90`) +- `src/components/campaign/municipality/TerritoryFilters.tsx` (`:31`–`:84`) +- `src/components/campaign/municipality/MunicipalityFilters.tsx` (`:35`–`:96`) +- `src/components/campaign/shared/CampaignListPending.tsx` — `useCampaignListPending`, reusado pelo hook novo +- AGENTS.md — módulo de dependência `lib/`→`utilities/`→`components/`; abstrações precisam de 3+ call sites (aqui há exatamente 3, o piso do próprio AGENTS.md) diff --git a/docs/plans/escala-dry-pos-e10.md b/docs/plans/escala-dry-pos-e10.md index de08b340..2e0012d5 100644 --- a/docs/plans/escala-dry-pos-e10.md +++ b/docs/plans/escala-dry-pos-e10.md @@ -79,7 +79,7 @@ Componentes: ## Adiado com gatilho -- **`TERRITORIAL_CLASSES` sem consumidor e as três derivações `Object.keys(...) as ...`.** Mover a tupla + tipos para módulo de contrato client-safe removeria o cast, mas `trend` usa o **mesmo** idioma no mesmo arquivo — corrigir só o novo deixa o arquivo menos consistente. Revisitar quando: 3º enum multi entrar na lista (o **B33** traz Partido em `/campanha/dobradinhas`), tratando trend+class+partido de uma vez. +- **`TERRITORIAL_CLASSES` sem consumidor e as três derivações `Object.keys(...) as ...`.** Mover a tupla + tipos para módulo de contrato client-safe removeria o cast, mas `trend` usa o **mesmo** idioma no mesmo arquivo — corrigir só o novo deixa o arquivo menos consistente. Revisitar quando: 3º enum **fechado** multi entrar na lista, tratando trend+class+esse novo de uma vez — **o B33 (entregue 2026-07-26) não disparou este gatilho**: Partido em `/campanha/dobradinhas` é texto livre (sentinela `NO_PARTY_FILTER_VALUE` para "sem partido"), não um enum fechado como `trend`/`class`, então `parseExhaustiveEnumParam` não se aplica a ele (confirmado no `/simplify` do B33). - **`TerritorialClassRow` reimplementa o trigger do `GoalAccountMetric`.** Só o estilo do botão é duplicado — o comportamento de toque/dismiss vive no `CampaignHoverTooltip` compartilhado, então o risco de regressão assimétrica é menor do que parece. Revisitar sob o gatilho **já registrado** em [conta-da-cadeira.md](conta-da-cadeira.md): quando E9–E14 adicionarem uma 5ª métrica a essa `dl`/grid, decidir layout/idioma antes de replicar o componente de novo. - **Três caches de módulo no classificador viram um só.** Legibilidade, não custo — o sort redundante já morreu no simplify. Revisitar quando **E15** recalibrar as âncoras (é quando alguém vai reler as três lifetimes de uma vez). diff --git a/docs/plans/ordenacao-filtros-lista-dobradinhas.md b/docs/plans/ordenacao-filtros-lista-dobradinhas.md index acd38d8f..0e5fd01e 100644 --- a/docs/plans/ordenacao-filtros-lista-dobradinhas.md +++ b/docs/plans/ordenacao-filtros-lista-dobradinhas.md @@ -130,6 +130,7 @@ Componentes: ## Adiado com gatilho - **Sort/filtro por `municipalityCount`/`leadershipCount`.** Revisitar quando: houver pedido nomeado de produto para "dobradinhas sem nenhuma liderança/município" como recorte de onboarding, ou quando outro item já precisar do mesmo agregado SQL sobre `stateDeputy` (ex.: um "C8-like" de escala). +- **Agregado SQL para o facet de partido em vez de escanear `stateDeputy` inteiro.** O facet hoje faz um `payload.find` de todos os registros (respeitando `q`) e agrega `party` em memória — só compensa a complexidade quando o volume de dobradinhas crescer o bastante para o full scan pesar (achado do `/simplify` B33 rodada 1, 2026-07-26). Revisitar quando: `stateDeputy` passar a ter volume comparável a `supporter`/`leadership` (centenas+), ou quando outro facet de lista já precisar do mesmo padrão de agregado (o `COUNT(*) FILTER` de `supporterListOverviewAggregate.ts`, do C6 ✓, é o precedente a seguir). ## Referências diff --git a/docs/roadmap.md b/docs/roadmap.md index 6784644f..41f4b1af 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap — Teqo -Atualizado em: 2026-07-26 (**B28 ✓** e-mail + celular na lista de lideranças entregue — colunas E-mail/Celular com copy-on-click + coluna de Ações com WhatsApp, no padrão de `/campanha/assessores` (B19 ✓); `shared/CampaignCopyableCell` nasce compartilhado já no 2º call site (`AdvisorsTable` migrado no mesmo PR), `whatsAppHrefForPhone` promovido a `lib/phone.ts`, `loadLeadershipDetail` deduplicado; [plano](plans/email-celular-lista-liderancas.md); **B24 ✓** auto-save + justificativa no popover de Tendência de `/campanha/municipios` — sem botão "Salvar", `POST /campanha/municipios/political-trend`, [plano](plans/autosave-tendencia-lista-municipios.md); **B13 ✓** escala relativa no mapa do Início entregue — quantis como novo default, padrão próprio/LQ nas âncoras do E10 e posição no município a partir do **artefato TSE v3** (`federalRankByIbgeCode`), mais bolhas opcionais por válidos projetados coloridas pela classe; `computeAggregateTerritorialClass` nasce aqui e o **E12** deve herdá-lo em vez de escrever um rollup próprio; `TERRITORIAL_CLASS_ANCHORS` mudou de casa para `src/lib/territorialClassAnchors.ts`; [plano](plans/escala-relativa-mapa.md); **B13+** novo fill-in de engenharia com os quatro débitos do `/simplify` do B13 — teste de concordância entre as duas escritas do LQ (mapa cliente × classificador server-only, a rede de segurança do **E15**), escopo geográfico declarado no readout (posição é da cidade inteira, classe é do escopo do usuário), uma varredura da fatia TSE por ano no script de agregados em vez de três, e a tupla no `competitiveRankByYear` que corta ~metade dos ~63,6 KB somados ao payload RSC · [plano](plans/escala-dry-pos-b13.md); **débito de teste** afinado no ledger ([TECH-DEBT.md](TECH-DEBT.md), P3 → P2): `campaignMunicipalities`/`campaign-pwa` são flaky com 2 workers **em `main`** também — clique no checkbox de consentimento antes da hidratação e regex de fixture em que "Conde" casa com "Condeúba"; **B27 ✓** combobox + chips + auto-save no popover de Assessores de `/campanha/municipios` entregue — a checkbox list + botão "Salvar assessores" virou chips removíveis + `Command` com gravação por delta, `nextAdvisorIdsAfterMembership` movido para `src/lib/`, endpoint `POST /campanha/municipios/advisors` espelho do `expected-votes/`, [plano](plans/combobox-assessores-lista-municipios.md); **B23 ✓** tooltip de conteúdo nas células das listas entregue — a mecânica genérica (`cellTooltip`, `CampaignCellTooltip`, `CampaignHoverTooltip` em `shared/`) já tinha saído de graça no E10 ✓; este item fechou `openOnTouch`/`disabled` no primitivo (mais um terceiro comportamento do `/simplify`: `content` falsy renderiza o trigger sem wrapper) e os dois consumidores de `/campanha/municipios` (nomes de assessores + justificativa da tendência), [plano](plans/tooltip-celulas-listas.md); **B22 ✓** explicação por coluna no header das listas entregue — `description?: ReactNode` em `CampaignTableHead`/`MunicipalitySortableHead` (rename de `tooltip`, sublinhado pontilhado), `campaignConceptOneLiner` como fonte única para colunas cobertas pelo E18 ✓, `municipalityColumnDescriptions` cobrindo as 11 colunas de `/campanha/municipios`; sem migration/action, [plano](plans/explicacao-colunas-header-listas.md); 2026-07-25: **E10 ✓** classificação territorial relativa entregue — Reduto/Expansão/Manutenção/Marginal/Sem base sobre o artefato TSE, coluna+sort+filtro na lista, badge no card do E8 e na capa do dossiê, e o seam genérico `cellTooltip` da `CampaignTable` (núcleo do B23) com `CampaignHoverTooltip` promovido para `shared/`; **B13** liberado; **E10+** novo fill-in de engenharia com os quatro débitos do `/simplify` do E10 — a lista lendo `municipality` duas vezes por request (o que **dispara o "Adiado com gatilho" do A11**: `classe` é o 4º path derivado no loader), memo de processo dos potenciais por município, `TooltipProvider` único no layout e os multi-filtros do mobile saindo das definitions · [plano](plans/escala-dry-pos-e10.md); **C8 F4b** novo fill-in — o guard de `formActions` só vê arquivos chamados exatamente `formActions.ts`, achado do `/simplify` do C13; **B26 ✓** registrar sinal na célula "Último sinal" — [plano](plans/registrar-sinal-lista-municipios.md); **C13 ✓** "Plano de Ação" → **"Atividade"** renomeado em toda a vertical — collection `activity`, rota `/campanha/atividades`, sidebar "Atividades", migration data-preserving escrita à mão (4 tabelas, 3 enums, 2 colunas, 39 índices, 14 FKs, 2 sequences + os 8 índices fósseis `%plaza%` da remodelagem) e guard de vocabulário table-driven varrendo `src`+`tests`+`scripts`; [plano](plans/renomear-plano-acao-para-atividade.md); **B37** chips editáveis de municípios na lista de dobradinhas — coluna "Municípios" de `/campanha/dobradinhas` troca o número morto (`municipalityCount`) por chips com link (município) / território (`MapPinIcon`) e o mesmo Popover+combobox do **B34** (busca por município/TI/ZE, lote de território/zona), mas escrevendo do lado invertido (`municipality.stateDeputies`, sem piso mínimo, lock por município em vez de por dobradinha, sem `assertMunicipalitiesWithinScope` porque `canUpdateMunicipality` já escopa o assessor); reaproveita/promove a mesma lib do B19/B34 (quem chegar primeiro faz a renomeação); mesmo conflito registrado com o pedido literal (toggle "Editar" de tabela inteira) que B28/B31/B32/B34/B36 já resolveram na direção do Popover por célula — decisão marcada como _proposta, a validar com produto_; sem migration, [plano](plans/chips-municipios-lista-dobradinhas.md); **B36** coluna "Lideranças" em `/campanha/dobradinhas` com a mesma UI/mecânica da coluna "Municípios" de `/campanha/assessores` (B19 ✓) — chips + Popover + `Command` + auto-save por delta + "Ver mais…" (clamp de 3 linhas por `ResizeObserver`, necessário aqui porque um deputado popular pode acumular muito mais lideranças do que uma liderança acumula dobradinhas); pedido explícito de não duplicar lógica — reusa **sem alteração** a mutação/schema/lock que o **B31** desenha para o lado inverso da mesma relação `leadership.stateDeputies`, promovendo a célula desse item para um componente compartilhado bidirecional (`LeadershipStateDeputyRelationCell`) em vez de um segundo componente-irmão; corrida de extração com o B31 (quem entrar primeiro paga a peça compartilhada); sem migration, [plano](plans/chips-liderancas-lista-dobradinhas.md); **B34** chips editáveis de municípios na lista de lideranças — coluna "Municípios" de `/campanha/liderancas` ganha chips com link (município) / território (`MapPinIcon`), edição por **Popover disparado por célula** com chips removíveis + combobox `Command` (busca por município/TI/ZE, lote de território/zona), gravação **por delta** direto em `leadership.municipalities` com lock `leadership-municipalities:{id}` (piso de 1 e teto de 30 respeitados) e `revalidatePath` (`liderancas`, `liderancas/[id]`, `municipios/[slug]` dos tocados); promove a lógica pura de `advisorMunicipalityPortfolio.ts`/`loadAdvisorMunicipalityIndex` (**B19 ✓**) para nomes neutros, sem portar a UI com `ResizeObserver`; **conflito registrado com o pedido literal** (pede um toggle "Editar" de tabela inteira, que B28/B31/B32 já travaram como anti-goal nesta mesma tabela — este plano segue o precedente e marca a decisão como _proposta, a validar com produto_); sem migration, [plano](plans/chips-municipios-lista-liderancas.md); ~~**B33**~~ ✓ ordenação e filtro no header da lista de dobradinhas (entregue 2026-07-26) — `?sort=`/`?dir=` por Nome/Partido + Popover multi-seleção de Partido com "Sem partido", consumindo o chrome compartilhado do **B21 ✓** (a corrida de ordem de chegada com B29 foi encerrada); paridade mobile (`StateDeputyFilters`) entrou por decisão desta sessão; ordem dos nulos de `party` pinada por int test; contagens derivadas (`municipalityCount`/`leadershipCount`) seguem sem sort/filtro, como travado, [plano](plans/ordenacao-filtros-lista-dobradinhas.md); **B32** editar Status de apoio na célula da lista de lideranças — clicar na célula abre um Popover com select de `supportStatus`, gravando sozinho por endpoint JSON dedicado (`POST /campanha/liderancas/support-status`, espelho do `expected-votes/`), **sem** o toggle "Editar" de tabela inteira (pedido explícito de produto, mesmo anti-goal do B28/B31); fecha o "Adiado com gatilho" que o próprio B29 já havia registrado para este caso; extrai `isSameOriginRequest` (2º endpoint) e as mensagens seguras do form action da ficha para módulos compartilhados; sem migration, [plano](plans/autosave-status-lista-liderancas.md); **B31** coluna de dobradinhas na lista de lideranças — chips com link + adicionar/remover na própria linha, com busca e gravação automática, na paridade pedida com a coluna "Municípios" de `/campanha/assessores` (B19 ✓) — paridade **da coluna**, não do modo "Editar" de tabela inteira (anti-goal do princípio 3 e do B28); escrita **por delta** com lock (`leadership-state-deputies:{id}`) em vez de replace do array, e `revalidatePath` porque `/campanha/dobradinhas` tem contagens derivadas; sem migration, [plano](plans/dobradinhas-lista-liderancas.md); **B30** convite de completar cadastro por WhatsApp na lista de lideranças — ação ao fim de cada linha de `/campanha/liderancas` reusando `createCampaignInvite`/`kind: 'autopreenchimento'` (mesmo fluxo do detalhe), disabled quando `contact.phone` não normaliza (`normalizeBrazilianPhone`); ícone distinto do WhatsApp de contato do B28 (soft, mesma linha); sem migration/action nova, [plano](plans/convite-whatsapp-lista-liderancas.md); **B29** ordenação e filtro no header da lista de lideranças — leva B15 ✓/B16 ✓ para `/campanha/liderancas` (sort/filtro na URL, popover no header, colunas "Setor" e "Última atualização"), ordenação por nome via join `contact.name` (sem desnormalizar), e é o **3º call site** que dispara a extração do par head/filtro para `shared/` (municípios migrado junto; B21 fecha seu adiado), [plano](plans/ordenacao-filtros-lista-liderancas.md); ~~**B28**~~ ✓ e-mail + celular na lista de `/campanha/liderancas` (entregue 2026-07-26) — colunas E-mail/Celular com copy-on-click + coluna de Ações com WhatsApp como em `/campanha/assessores` (B19 ✓); `CampaignCopyableCell` nasce compartilhado já no 2º call site (`AdvisorsTable` migrado no mesmo PR), `whatsAppHrefForPhone` promovido a `lib/phone.ts`; edição in-list adiada; sem migration, [plano](plans/email-celular-lista-liderancas.md); ~~**B27**~~ ✓ combobox + chips + auto-save no popover de Assessores de `/campanha/municipios` (entregue 2026-07-26), [plano](plans/combobox-assessores-lista-municipios.md); ~~**B26**~~ ✓ registrar sinal na célula "Último sinal" (entregue 2026-07-25), [plano](plans/registrar-sinal-lista-municipios.md); **fill-in** renomear coluna "Cobertura da meta" → "Cobertura" em `/campanha/municipios` — header curto (padrão A11); glossário/KPI strips intactos; B16 ✓ liberou o nome ao remover "Assessoria", [plano](plans/renomear-coluna-cobertura-meta.md); **B25** link do território na lista de municípios — a célula da coluna "Território" vira link para a linha daquele TI em `/campanha/territorios` (âncora, sem rota de detalhe); dep dura B21, [plano](plans/link-territorio-lista-municipios.md); ~~**B24**~~ ✓ auto-save + justificativa editável no popover de Tendência de `/campanha/municipios` (entregue 2026-07-26), [plano](plans/autosave-tendencia-lista-municipios.md); **fill-in** remover coluna "Tipo" de `/campanha/municipios` — município e ZE tratados iguais na coordenação; filtro por Salvador cobre as zonas; drop também do filtro/sort/`?kind=` na URL da lista, schema `kind` intacto, [plano](plans/remover-coluna-tipo-municipios.md); ~~**B23**~~ ✓ tooltip de conteúdo nas células das listas (entregue 2026-07-26), [plano](plans/tooltip-celulas-listas.md); **fill-in** opções selecionadas no topo nos Popovers de filtro do header — helper puro reutilizável pelas demais listas, [plano](plans/selecionados-no-topo-filtros-header.md); ~~**B22**~~ ✓ explicação por coluna no header das listas (entregue 2026-07-26), [plano](plans/explicacao-colunas-header-listas.md); **B21** página própria dos Territórios de Identidade — rota `/campanha/territorios` com overview + tabela no sistema de listas (sort/filtro no header), sem migration, [plano](plans/pagina-territorios-identidade.md); **B20 ✓** Municípios prioritários no Início entregue 2026-07-25 — atalho ranqueado por déficit + markup sem bullets, [plano](plans/municipios-prioritarios-inicio.md); **Pass 2 ✓** consolidação de engenharia — listas genéricas/B16+ absorvido/B17-B18 triviais/E10 limpo/C8 F4 ✓/D3 consent WhatsApp; **B16 ✓**; **E16 ✓**; **E9 ✓**; **E18 ✓**; **B19 ✓**; **E8 ✓**; C12 ✓; B18; B17; A11 ✓; B15 ✓; E4R ✓; E17 ✓; B14; janela 1: smoke, R6) +Atualizado em: 2026-07-26 (**B33+** novo fill-in de engenharia registrado no fechamento do B33 — `StateDeputyFilters` é o 3º call site do scaffold de navegação/debounce que `TerritoryFilters`/`MunicipalityFilters` já tinham, achado do `/simplify` rodada 2, [plano](plans/escala-dry-pos-b33.md); **B28 ✓** e-mail + celular na lista de lideranças entregue — colunas E-mail/Celular com copy-on-click + coluna de Ações com WhatsApp, no padrão de `/campanha/assessores` (B19 ✓); `shared/CampaignCopyableCell` nasce compartilhado já no 2º call site (`AdvisorsTable` migrado no mesmo PR), `whatsAppHrefForPhone` promovido a `lib/phone.ts`, `loadLeadershipDetail` deduplicado; [plano](plans/email-celular-lista-liderancas.md); **B24 ✓** auto-save + justificativa no popover de Tendência de `/campanha/municipios` — sem botão "Salvar", `POST /campanha/municipios/political-trend`, [plano](plans/autosave-tendencia-lista-municipios.md); **B13 ✓** escala relativa no mapa do Início entregue — quantis como novo default, padrão próprio/LQ nas âncoras do E10 e posição no município a partir do **artefato TSE v3** (`federalRankByIbgeCode`), mais bolhas opcionais por válidos projetados coloridas pela classe; `computeAggregateTerritorialClass` nasce aqui e o **E12** deve herdá-lo em vez de escrever um rollup próprio; `TERRITORIAL_CLASS_ANCHORS` mudou de casa para `src/lib/territorialClassAnchors.ts`; [plano](plans/escala-relativa-mapa.md); **B13+** novo fill-in de engenharia com os quatro débitos do `/simplify` do B13 — teste de concordância entre as duas escritas do LQ (mapa cliente × classificador server-only, a rede de segurança do **E15**), escopo geográfico declarado no readout (posição é da cidade inteira, classe é do escopo do usuário), uma varredura da fatia TSE por ano no script de agregados em vez de três, e a tupla no `competitiveRankByYear` que corta ~metade dos ~63,6 KB somados ao payload RSC · [plano](plans/escala-dry-pos-b13.md); **débito de teste** afinado no ledger ([TECH-DEBT.md](TECH-DEBT.md), P3 → P2): `campaignMunicipalities`/`campaign-pwa` são flaky com 2 workers **em `main`** também — clique no checkbox de consentimento antes da hidratação e regex de fixture em que "Conde" casa com "Condeúba"; **B27 ✓** combobox + chips + auto-save no popover de Assessores de `/campanha/municipios` entregue — a checkbox list + botão "Salvar assessores" virou chips removíveis + `Command` com gravação por delta, `nextAdvisorIdsAfterMembership` movido para `src/lib/`, endpoint `POST /campanha/municipios/advisors` espelho do `expected-votes/`, [plano](plans/combobox-assessores-lista-municipios.md); **B23 ✓** tooltip de conteúdo nas células das listas entregue — a mecânica genérica (`cellTooltip`, `CampaignCellTooltip`, `CampaignHoverTooltip` em `shared/`) já tinha saído de graça no E10 ✓; este item fechou `openOnTouch`/`disabled` no primitivo (mais um terceiro comportamento do `/simplify`: `content` falsy renderiza o trigger sem wrapper) e os dois consumidores de `/campanha/municipios` (nomes de assessores + justificativa da tendência), [plano](plans/tooltip-celulas-listas.md); **B22 ✓** explicação por coluna no header das listas entregue — `description?: ReactNode` em `CampaignTableHead`/`MunicipalitySortableHead` (rename de `tooltip`, sublinhado pontilhado), `campaignConceptOneLiner` como fonte única para colunas cobertas pelo E18 ✓, `municipalityColumnDescriptions` cobrindo as 11 colunas de `/campanha/municipios`; sem migration/action, [plano](plans/explicacao-colunas-header-listas.md); 2026-07-25: **E10 ✓** classificação territorial relativa entregue — Reduto/Expansão/Manutenção/Marginal/Sem base sobre o artefato TSE, coluna+sort+filtro na lista, badge no card do E8 e na capa do dossiê, e o seam genérico `cellTooltip` da `CampaignTable` (núcleo do B23) com `CampaignHoverTooltip` promovido para `shared/`; **B13** liberado; **E10+** novo fill-in de engenharia com os quatro débitos do `/simplify` do E10 — a lista lendo `municipality` duas vezes por request (o que **dispara o "Adiado com gatilho" do A11**: `classe` é o 4º path derivado no loader), memo de processo dos potenciais por município, `TooltipProvider` único no layout e os multi-filtros do mobile saindo das definitions · [plano](plans/escala-dry-pos-e10.md); **C8 F4b** novo fill-in — o guard de `formActions` só vê arquivos chamados exatamente `formActions.ts`, achado do `/simplify` do C13; **B26 ✓** registrar sinal na célula "Último sinal" — [plano](plans/registrar-sinal-lista-municipios.md); **C13 ✓** "Plano de Ação" → **"Atividade"** renomeado em toda a vertical — collection `activity`, rota `/campanha/atividades`, sidebar "Atividades", migration data-preserving escrita à mão (4 tabelas, 3 enums, 2 colunas, 39 índices, 14 FKs, 2 sequences + os 8 índices fósseis `%plaza%` da remodelagem) e guard de vocabulário table-driven varrendo `src`+`tests`+`scripts`; [plano](plans/renomear-plano-acao-para-atividade.md); **B37** chips editáveis de municípios na lista de dobradinhas — coluna "Municípios" de `/campanha/dobradinhas` troca o número morto (`municipalityCount`) por chips com link (município) / território (`MapPinIcon`) e o mesmo Popover+combobox do **B34** (busca por município/TI/ZE, lote de território/zona), mas escrevendo do lado invertido (`municipality.stateDeputies`, sem piso mínimo, lock por município em vez de por dobradinha, sem `assertMunicipalitiesWithinScope` porque `canUpdateMunicipality` já escopa o assessor); reaproveita/promove a mesma lib do B19/B34 (quem chegar primeiro faz a renomeação); mesmo conflito registrado com o pedido literal (toggle "Editar" de tabela inteira) que B28/B31/B32/B34/B36 já resolveram na direção do Popover por célula — decisão marcada como _proposta, a validar com produto_; sem migration, [plano](plans/chips-municipios-lista-dobradinhas.md); **B36** coluna "Lideranças" em `/campanha/dobradinhas` com a mesma UI/mecânica da coluna "Municípios" de `/campanha/assessores` (B19 ✓) — chips + Popover + `Command` + auto-save por delta + "Ver mais…" (clamp de 3 linhas por `ResizeObserver`, necessário aqui porque um deputado popular pode acumular muito mais lideranças do que uma liderança acumula dobradinhas); pedido explícito de não duplicar lógica — reusa **sem alteração** a mutação/schema/lock que o **B31** desenha para o lado inverso da mesma relação `leadership.stateDeputies`, promovendo a célula desse item para um componente compartilhado bidirecional (`LeadershipStateDeputyRelationCell`) em vez de um segundo componente-irmão; corrida de extração com o B31 (quem entrar primeiro paga a peça compartilhada); sem migration, [plano](plans/chips-liderancas-lista-dobradinhas.md); **B34** chips editáveis de municípios na lista de lideranças — coluna "Municípios" de `/campanha/liderancas` ganha chips com link (município) / território (`MapPinIcon`), edição por **Popover disparado por célula** com chips removíveis + combobox `Command` (busca por município/TI/ZE, lote de território/zona), gravação **por delta** direto em `leadership.municipalities` com lock `leadership-municipalities:{id}` (piso de 1 e teto de 30 respeitados) e `revalidatePath` (`liderancas`, `liderancas/[id]`, `municipios/[slug]` dos tocados); promove a lógica pura de `advisorMunicipalityPortfolio.ts`/`loadAdvisorMunicipalityIndex` (**B19 ✓**) para nomes neutros, sem portar a UI com `ResizeObserver`; **conflito registrado com o pedido literal** (pede um toggle "Editar" de tabela inteira, que B28/B31/B32 já travaram como anti-goal nesta mesma tabela — este plano segue o precedente e marca a decisão como _proposta, a validar com produto_); sem migration, [plano](plans/chips-municipios-lista-liderancas.md); ~~**B33**~~ ✓ ordenação e filtro no header da lista de dobradinhas (entregue 2026-07-26) — `?sort=`/`?dir=` por Nome/Partido + Popover multi-seleção de Partido com "Sem partido", consumindo o chrome compartilhado do **B21 ✓** (a corrida de ordem de chegada com B29 foi encerrada); paridade mobile (`StateDeputyFilters`) entrou por decisão desta sessão; ordem dos nulos de `party` pinada por int test; contagens derivadas (`municipalityCount`/`leadershipCount`) seguem sem sort/filtro, como travado, [plano](plans/ordenacao-filtros-lista-dobradinhas.md); **B32** editar Status de apoio na célula da lista de lideranças — clicar na célula abre um Popover com select de `supportStatus`, gravando sozinho por endpoint JSON dedicado (`POST /campanha/liderancas/support-status`, espelho do `expected-votes/`), **sem** o toggle "Editar" de tabela inteira (pedido explícito de produto, mesmo anti-goal do B28/B31); fecha o "Adiado com gatilho" que o próprio B29 já havia registrado para este caso; extrai `isSameOriginRequest` (2º endpoint) e as mensagens seguras do form action da ficha para módulos compartilhados; sem migration, [plano](plans/autosave-status-lista-liderancas.md); **B31** coluna de dobradinhas na lista de lideranças — chips com link + adicionar/remover na própria linha, com busca e gravação automática, na paridade pedida com a coluna "Municípios" de `/campanha/assessores` (B19 ✓) — paridade **da coluna**, não do modo "Editar" de tabela inteira (anti-goal do princípio 3 e do B28); escrita **por delta** com lock (`leadership-state-deputies:{id}`) em vez de replace do array, e `revalidatePath` porque `/campanha/dobradinhas` tem contagens derivadas; sem migration, [plano](plans/dobradinhas-lista-liderancas.md); **B30** convite de completar cadastro por WhatsApp na lista de lideranças — ação ao fim de cada linha de `/campanha/liderancas` reusando `createCampaignInvite`/`kind: 'autopreenchimento'` (mesmo fluxo do detalhe), disabled quando `contact.phone` não normaliza (`normalizeBrazilianPhone`); ícone distinto do WhatsApp de contato do B28 (soft, mesma linha); sem migration/action nova, [plano](plans/convite-whatsapp-lista-liderancas.md); **B29** ordenação e filtro no header da lista de lideranças — leva B15 ✓/B16 ✓ para `/campanha/liderancas` (sort/filtro na URL, popover no header, colunas "Setor" e "Última atualização"), ordenação por nome via join `contact.name` (sem desnormalizar), e é o **3º call site** que dispara a extração do par head/filtro para `shared/` (municípios migrado junto; B21 fecha seu adiado), [plano](plans/ordenacao-filtros-lista-liderancas.md); ~~**B28**~~ ✓ e-mail + celular na lista de `/campanha/liderancas` (entregue 2026-07-26) — colunas E-mail/Celular com copy-on-click + coluna de Ações com WhatsApp como em `/campanha/assessores` (B19 ✓); `CampaignCopyableCell` nasce compartilhado já no 2º call site (`AdvisorsTable` migrado no mesmo PR), `whatsAppHrefForPhone` promovido a `lib/phone.ts`; edição in-list adiada; sem migration, [plano](plans/email-celular-lista-liderancas.md); ~~**B27**~~ ✓ combobox + chips + auto-save no popover de Assessores de `/campanha/municipios` (entregue 2026-07-26), [plano](plans/combobox-assessores-lista-municipios.md); ~~**B26**~~ ✓ registrar sinal na célula "Último sinal" (entregue 2026-07-25), [plano](plans/registrar-sinal-lista-municipios.md); **fill-in** renomear coluna "Cobertura da meta" → "Cobertura" em `/campanha/municipios` — header curto (padrão A11); glossário/KPI strips intactos; B16 ✓ liberou o nome ao remover "Assessoria", [plano](plans/renomear-coluna-cobertura-meta.md); **B25** link do território na lista de municípios — a célula da coluna "Território" vira link para a linha daquele TI em `/campanha/territorios` (âncora, sem rota de detalhe); dep dura B21, [plano](plans/link-territorio-lista-municipios.md); ~~**B24**~~ ✓ auto-save + justificativa editável no popover de Tendência de `/campanha/municipios` (entregue 2026-07-26), [plano](plans/autosave-tendencia-lista-municipios.md); **fill-in** remover coluna "Tipo" de `/campanha/municipios` — município e ZE tratados iguais na coordenação; filtro por Salvador cobre as zonas; drop também do filtro/sort/`?kind=` na URL da lista, schema `kind` intacto, [plano](plans/remover-coluna-tipo-municipios.md); ~~**B23**~~ ✓ tooltip de conteúdo nas células das listas (entregue 2026-07-26), [plano](plans/tooltip-celulas-listas.md); **fill-in** opções selecionadas no topo nos Popovers de filtro do header — helper puro reutilizável pelas demais listas, [plano](plans/selecionados-no-topo-filtros-header.md); ~~**B22**~~ ✓ explicação por coluna no header das listas (entregue 2026-07-26), [plano](plans/explicacao-colunas-header-listas.md); **B21** página própria dos Territórios de Identidade — rota `/campanha/territorios` com overview + tabela no sistema de listas (sort/filtro no header), sem migration, [plano](plans/pagina-territorios-identidade.md); **B20 ✓** Municípios prioritários no Início entregue 2026-07-25 — atalho ranqueado por déficit + markup sem bullets, [plano](plans/municipios-prioritarios-inicio.md); **Pass 2 ✓** consolidação de engenharia — listas genéricas/B16+ absorvido/B17-B18 triviais/E10 limpo/C8 F4 ✓/D3 consent WhatsApp; **B16 ✓**; **E16 ✓**; **E9 ✓**; **E18 ✓**; **B19 ✓**; **E8 ✓**; C12 ✓; B18; B17; A11 ✓; B15 ✓; E4R ✓; E17 ✓; B14; janela 1: smoke, R6) Registro canônico dos **próximos** planos e débitos. Histórico de entregas: resumo abaixo + planos em [`docs/plans/`](plans/) + notebook [`.cursor/rules/projects/nucleos-eleitorais.mdc`](../.cursor/rules/projects/nucleos-eleitorais.mdc). ## Âncoras do calendário eleitoral 2026 (Res. TSE 23.760/2026) @@ -132,6 +132,7 @@ O discovery literatura→persona→entrevista ([relatório aprovado](research/re - **C10 / C11** escala apoiadores/planos · C11 absorveu a extensão pós-C12 para picker de planos e paginação das demandas vinculadas (Fase 6 condicional; ≥100 planos ou ≥50 demandas/plano) · gatilhos: base nominal crescendo ([plano](plans/escala-dry-pos-c9.md)) / volume de planos medido ([plano](plans/escala-dry-pos-c7.md)) - **C8 F1–F2 restantes** perf de import/lista (F3 parsers fechou no Pass 2 W1-D2) · gatilho: import em volume real pós-Onda 0 · [plano](plans/escala-dry-pos-c6.md) - **C8 F4b** o guard de `formActions` só inspeciona arquivos chamados exatamente `formActions.ts`, então os irmãos que cada rota escreveu depois (`lifecycleFormActions.ts`, `resultFormActions.ts`, `updateFormActions.ts`, `taskActions.ts`) ficaram sem verificação — ampliar o filtro e migrar o que `runCampaignRedirectFormAction` cobrir · achado do `/simplify` do C13 · ~2h eng · [plano](plans/escala-dry-pos-c6.md) +- **B33+** escala/DRY pós-B33 — `StateDeputyFilters` é o **3º call site** do mesmo scaffold de navegação (`SEARCH_DEBOUNCE_MS`, debounce/cleanup, `navigateTo`/`scheduleSearch`) que `TerritoryFilters` (B21 ✓) e `MunicipalityFilters` (B16 ✓) já tinham, achado do `/simplify` do B33 — extrai `useCampaignListFilterNavigation` em `shared/`, sem tocar o JSX/markup de cada filtro nem o contrato de URL · ~0,5d eng · Impeccable A · sem migration · cortável · [plano](plans/escala-dry-pos-b33.md) - Higiene PascalCase de componentes legados ### A validar (assumptions)