Merge branch 'cursor/2f22c6f6'

This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-26 16:28:12 -03:00
commit f63cd2cd50
18 changed files with 1156 additions and 102 deletions

View file

@ -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; 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 <nome>"`) 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 ? <CampaignHoverTooltip>… : 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 `<thead>` (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).

View file

@ -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 `<form>` (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/<domínio>` → 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 `<form>`/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<br/>(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 `<form>`/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)

View file

@ -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).

View file

@ -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<StateDeputy, 'party'>`) 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/`.
@ -112,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

File diff suppressed because one or more lines are too long

View file

@ -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<Record<string, string | string[] | undefined>>
}
const stateDeputyColumns: Array<CampaignTableColumn<StateDeputyRowViewModel>> = [
const StateDeputyListEmptyState = ({ state }: { state: StateDeputyListState }) => (
<Empty className="min-h-56">
<EmptyHeader>
<EmptyMedia variant="icon">
<SearchXIcon aria-hidden="true" />
</EmptyMedia>
<EmptyTitle>Nenhuma dobradinha encontrada</EmptyTitle>
<EmptyDescription>
Ajuste a busca ou os filtros para encontrar as dobradinhas.
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
{/* Same contract as the filter bar's Limpar: drop filters, keep the sort. */}
<CampaignTransitionAnchor
href={buildStateDeputyListHref(clearStateDeputyListFilters(state), 1)}
replace
scroll={false}
className={cn(buttonVariants({ variant: 'outline' }), 'min-h-11')}
>
Limpar busca e filtros
</CampaignTransitionAnchor>
</EmptyContent>
</Empty>
)
const stateDeputyColumns = (
state: StateDeputyListState,
partyFilterOptions: StateDeputyFilterOption[],
hasNoPartyOption: boolean,
): Array<CampaignTableColumn<StateDeputyRowViewModel>> => [
{
id: 'name',
mandatory: true,
head: <CampaignTableHead>Nome</CampaignTableHead>,
head: (
<StateDeputySortableHead state={state} sortKey="name">
Nome
</StateDeputySortableHead>
),
cell: (row) => (
<Link
href={`/campanha/dobradinhas/${row.slug}`}
@ -47,7 +99,16 @@ const stateDeputyColumns: Array<CampaignTableColumn<StateDeputyRowViewModel>> =
},
{
id: 'party',
head: <CampaignTableHead>Partido</CampaignTableHead>,
head: (
<StateDeputySortableHead
state={state}
sortKey="party"
filterOptions={partyFilterOptions}
hasNoPartyOption={hasNoPartyOption}
>
Partido
</StateDeputySortableHead>
),
cellClassName: 'text-muted-foreground',
cell: (row) => row.party ?? '—',
},
@ -67,12 +128,25 @@ const stateDeputyColumns: Array<CampaignTableColumn<StateDeputyRowViewModel>> =
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 (
<CampaignPageShell>
@ -93,40 +167,33 @@ export default async function StateDeputiesPage({ searchParams }: StateDeputiesP
</header>
<CampaignListPendingBoundary>
<CampaignSearchForm
ariaLabel="Buscar dobradinha por nome"
placeholder="Buscar por nome…"
initialQuery={state.q ?? ''}
basePath="/campanha/dobradinhas"
<StateDeputyFilters
state={state}
partyOptions={partyFilterOptions}
hasNoParty={filterFacets.hasNoParty}
/>
<CampaignListResults>
<p className="text-sm text-muted-foreground" aria-live="polite">
{sortSummary}
</p>
<CampaignTable
caption={`${sortSummary}. Deputados estaduais com quem a campanha dobra.`}
columns={stateDeputyColumns(state, partyFilterOptions, filterFacets.hasNoParty)}
rows={rows}
rowKey={(row) => row.id}
empty={<StateDeputyListEmptyState state={state} />}
/>
{rows.length ? (
<>
<CampaignTable columns={stateDeputyColumns} rows={rows} rowKey={(row) => row.id} />
<CampaignListFooter
totalDocs={totalDocs}
singular="dobradinha"
plural="dobradinhas"
page={state.page}
totalPages={totalPages}
hrefForPage={(page) => buildStateDeputyListHref(state, page)}
/>
</>
) : (
<CampaignListEmptyState
icon={SearchXIcon}
title="Nenhuma dobradinha cadastrada"
description="Cadastre deputados estaduais parceiros para vincular a municípios e lideranças."
>
<Button asChild className="min-h-11">
<Link href="/campanha/dobradinhas/nova">
<PlusIcon data-icon="inline-start" aria-hidden="true" />
Nova dobradinha
</Link>
</Button>
</CampaignListEmptyState>
)}
<CampaignListFooter
totalDocs={totalDocs}
singular="dobradinha"
plural="dobradinhas"
page={state.page}
totalPages={totalPages}
hrefForPage={(page) => buildStateDeputyListHref(state, page)}
/>
) : null}
</CampaignListResults>
</CampaignListPendingBoundary>
</CampaignPageShell>

View file

@ -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<ReturnType<typeof setTimeout> | 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 (
<form
role="search"
className="flex flex-col gap-3 transition-opacity data-[pending=true]:opacity-70"
data-pending={isPending}
aria-busy={isPending}
onSubmit={(event) => {
event.preventDefault()
navigateTo({ ...state, q: search, page: 1 })
}}
>
<p className="sr-only" aria-live="polite">
{isPending ? 'Atualizando resultados…' : ''}
</p>
<div className="flex flex-col gap-3 md:flex-row md:items-end">
<CampaignSearchInput
id="state-deputy-search"
label="Buscar dobradinha"
placeholder="Buscar por nome…"
value={search}
onChange={(event) => {
setSearch(event.target.value)
scheduleSearch(event.target.value)
}}
/>
{activeSummary ? (
<p className="hidden min-w-0 flex-1 text-sm text-muted-foreground md:block md:self-center md:pb-2">
{activeSummary}
</p>
) : null}
{activeSummary ? (
<Button
type="button"
variant="ghost"
className="min-h-11 md:self-end"
onClick={() => {
clearDebounce()
setSearch('')
navigateTo(clearStateDeputyListFilters(state))
}}
>
Limpar
</Button>
) : null}
</div>
<div className="flex flex-col gap-3 md:hidden">
{activeSummary ? <p className="text-sm text-muted-foreground">{activeSummary}</p> : null}
<CampaignMobileMultiFilterField
id="state-deputy-filter-party"
label="Partido"
emptyLabel="Todos"
options={mobilePartyOptions}
selected={state.parties ?? []}
onToggle={(value) => navigateTo(toggleStateDeputyPartyFilter(state, value))}
/>
<Field>
<FieldLabel htmlFor="state-deputy-sort">Ordenar</FieldLabel>
<NativeSelect
id="state-deputy-sort"
value={serializeStateDeputySortValue(sort, dir)}
onChange={(event) => {
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) => (
<NativeSelectOption
key={serializeStateDeputySortValue(option.key, option.dir)}
value={serializeStateDeputySortValue(option.key, option.dir)}
>
{option.label}
</NativeSelectOption>
))}
</NativeSelect>
</Field>
</div>
</form>
)
}

View file

@ -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 (
<CampaignHeaderFilterPopover
id="state-deputy-filter-party"
label="Partido"
active={active}
optionRows={rows}
clear={
active
? {
href: buildStateDeputyListHref(clearedState, 1),
onChoose: () => setOptimisticState(clearedState),
}
: undefined
}
/>
)
}

View file

@ -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 (
<CampaignSortableHead
active={active}
align={align}
className={className}
dir={dir}
href={buildStateDeputySortHref(state, sortKey)}
nextDir={nextDir}
sortLabel={stateDeputyListSortLabels[sortKey]}
filter={
filterOptions ? (
<StateDeputyHeaderFilter
state={state}
options={filterOptions}
hasNoParty={hasNoPartyOption}
/>
) : null
}
>
{children ?? stateDeputyListSortLabels[sortKey]}
</CampaignSortableHead>
)
}

View file

@ -30,6 +30,10 @@ export const parseExhaustiveEnumParam = <T extends string>(
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()

View file

@ -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`)

View file

@ -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<StateDeputyListFilterFacets> => {
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<string>(
(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<number, number>()
@ -121,6 +148,7 @@ export const loadStateDeputyListPageData = async (
})),
totalDocs: result.totalDocs,
totalPages: result.totalPages,
filterFacets,
}
}

View file

@ -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
}

View file

@ -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<string>(stateDeputyListParamNames)
const DEFAULT_STATE_DEPUTY_LIST_SORT_KEY: StateDeputyListSortKey = 'name'
const DEFAULT_STATE_DEPUTY_LIST_SORT_DIR: StateDeputyListSortDirection = 'asc'
export const stateDeputyListSortLabels: Record<StateDeputyListSortKey, string> = {
name: 'Nome',
party: 'Partido',
}
const stateDeputyListSortKeySet = new Set<string>(Object.keys(stateDeputyListSortLabels))
const stateDeputyListSortDirSet = new Set<StateDeputyListSortDirection>(['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,
})

View file

@ -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

View file

@ -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', () => {

View file

@ -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

View file

@ -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"')
})
})