Deliver auto-applied filters on Praças list and close filtros-auto fill-in.

Remove the Buscar step with debounced search, immediate select navigation, and pending UI; update roadmap, plan, and notebook.
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-21 12:54:23 -03:00
parent 7fe45ea963
commit 248f896d05
6 changed files with 119 additions and 32 deletions

View file

@ -7,6 +7,7 @@ alwaysApply: false
## Status
- **Fill-in filtros-auto entregue em código (2026-07-21; deploy pendente com remodelagem):** debounce 1s + Enter imediato na busca de `/campanha/pracas`; remove Buscar; pending UI alinhado a `ActionPlanFilters`; `shouldUpdatePlazaSearchUrl` + no-op `buildPlazaFiltersKey`. Plano: [`filtros-auto-pracas.md`](../../../docs/plans/filtros-auto-pracas.md). Débitos pós-`/simplify` (sync back/forward, shell pending) → gatilhos no plano.
- **B10 entregue em código (2026-07-21; deploy pendente com remodelagem):** hover/tap no Mapa das Praças — destaque + `MapFeatureReadout`; desktop click navega; mobile 2º tap; SSA/CMS N>1 foca `zoneBreakdown`; `plazasByIbgeCode` / `resolvePlazaMapNavigation`. Plano: [`hover-mapa-pracas.md`](../../../docs/plans/hover-mapa-pracas.md). Débito perf hover O(n) → **B6** [`escala-dry-pos-b3.md`](../../../docs/plans/escala-dry-pos-b3.md).
- **B9 entregue em código (2026-07-21; deploy pendente com remodelagem):** edição rápida na lista — Assessores / Tendência / `expectedVotes` via `PlazaList*Control` + `listFormActions`; sem migration. Plano: [`edicao-rapida-lista-pracas.md`](../../../docs/plans/edicao-rapida-lista-pracas.md). Débitos `/simplify` → **A9+** F2 + **C8** F4.
- **A9 entregue em código (2026-07-21; deploy pendente com remodelagem):** `plaza.expectedVotes` staff-only, fallback `expectedVotes ?? effectiveTotal` em mapa 2026/overview/dashboard, UI `/editar` + leitura lista/detalhe; migration `20260721_133444_add_plaza_expected_votes`. Plano: [`estimativa-votos-praca.md`](../../../docs/plans/estimativa-votos-praca.md). Fill-in pós-`/simplify`: **A9+** [`escala-dry-pos-a9.md`](../../../docs/plans/escala-dry-pos-a9.md) (loader compartilhado + revalidate escopada pós-B9).
@ -123,6 +124,6 @@ alwaysApply: false
- Em 2026-07-17 o roadmap foi repriorizado orientado ao calendário eleitoral (Res. TSE 23.760/2026: propaganda a partir de 16/08, 1º turno 04/10), com ordem de execução, trilhas paralelas (A dados eleitorais/território, B superfícies de coordenação, C operação de campo, D plataforma) e grafo de dependências. Decisões de sequenciamento: lançar o MVP antes da migração multi-município; multi-município antes de zonas-por-municipio/eventos/demandas; baseline TSE independente de zonas-por-municipio (dependência rebaixada a suave); WhatsApp Business API movido a fora de escopo (vedação Meta + Res. 23.610 art. 33).
- Backlog consolidado do produto (admin RBAC, mapa, eventos, demandas, apoiadores, listas globais, site, white-label): [`docs/roadmap.md`](../../../docs/roadmap.md).
- **B7 entregue (2026-07-21):** mapa em `/campanha/pracas` usa `buildPlazaListWhere` (lista/overview/mapa alinhados). Plano: [`docs/plans/mapa-pracas-filtrado.md`](../../../docs/plans/mapa-pracas-filtrado.md). Débito perf triplicado → **A9+**.
- **A9 / B9 / B7 / B10 entregues (2026-07-21; deploy pendente):** ver bullets no topo do Status. Próximos abertos Janela 1: **B11**, **R6**, filtros-auto, deploy+smoke remodelagem; Janela 3: **B6** (setStyle incremental pós-B10), **B8**.
- **A9 / B9 / B7 / B10 / filtros-auto entregues (2026-07-21; deploy pendente):** ver bullets no topo do Status. Próximos abertos Janela 1: **B11**, **R6**, deploy+smoke remodelagem; Janela 3: **B6** (setStyle incremental pós-B10), **B8**.
- **B8 registrado (2026-07-21):** polígonos das Praças-zona Salvador/Camaçari. Pesquisa: SSA tem lista oficial TRE-BA zona↔bairro (RA 02/2017); CMS **não** — só locais de votação por ZE 170/171 + curadoria; TSE/TRE não publicam polígonos de ZE. Plano: F1 catálogo+UI na Praça; F2 dissolve malha de bairros (IBGE/GeoSalvador) → TopoJSON. Janela 3; F2 cortável. Plano: [`docs/plans/poligonos-pracas-zona.md`](../../../docs/plans/poligonos-pracas-zona.md).
- **B11 registrado (2026-07-21):** opção de escala no Mapa das Praças — cor por % dos votos válidos da Praça (0–100% fixos; denominador `electionTally.votosValidos`; 2026 usa válidos 2022); absoluto permanece. Plano: [`docs/plans/escala-percentual-mapa-pracas.md`](../../../docs/plans/escala-percentual-mapa-pracas.md).

View file

@ -1,12 +1,14 @@
# Filtros auto-aplicados na lista de Praças
Status: rascunho
Status: entregue em código (2026-07-21; deploy pendente com remodelagem)
Atualizado em: 2026-07-21
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Fill-ins)
Impeccable: B — encaixe em `PlazaFilters` (`/campanha/pracas`); sem rota nova
Appetite: ~0,5 dia eng; só client em `PlazaFilters` (+ pending a11y); sem migration
Responsável: —
_Revisão 2026-07-21 (pós-implementação + `/simplify` + capture-review-debts): debounce 1s + Enter imediato + pending UI; `commitNavigation` com `buildPlazaFiltersKey` no-op; `shouldUpdatePlazaSearchUrl` em `plazaUi.ts`; testes `tests/unit/plazaUi.unit.spec.ts`. Débitos S1–S2 registrados em Adiado (não reabrir no simplify)._
## Design (Impeccable)
Âncoras: `PRODUCT.md` / `DESIGN.md` (register product — Field Desk) · tema `data-theme='campaign'` · shells existentes (`CampaignPageShell`, `CampaignSearchInput`, `PlazaFilters`).
@ -78,7 +80,7 @@ Componentes:
## Dependências
- **Dura:** nenhuma de outro plano (R2 já entregue).
- **Suave:** B7 ([mapa-pracas-filtrado.md](mapa-pracas-filtrado.md)) — debounce aumenta frequência de troca de filtro; mapa filtrado fica mais útil, mas este item funciona com o mapa ainda “all accessible”.
- **Suave:** B7 ([mapa-pracas-filtrado.md](mapa-pracas-filtrado.md)) ✓ entregue — cada replace de filtro também refresca o mapa filtrado.
- Reusa: `plazaUi.ts` (`buildPlazaListHref`, `PlazaListState`), padrão pending de `ActionPlanFilters.tsx`, dwell do `RecentVisitTracker` (já re-arma o timer a cada `href` — debounce 1s + dwell ~2s evita visitar intermediários).
## Não escopo
@ -94,8 +96,18 @@ Componentes:
- **Debounce via lib (`use-debounce` etc.).** Dependência nova por 10 linhas. **Mitigação:** `setTimeout` + cleanup.
- **Otimistic list client-side.** Duplicaria `buildPlazaListWhere` no browser. **Mitigação:** URL + RSC como hoje.
## Já resolvido no simplify/critique (não reabrir)
- Alias `plazaSearchParamFromInput` removido; `normalizedText` + `shouldUpdatePlazaSearchUrl` em `plazaUi.ts`.
- No-op de navegação via `buildPlazaFiltersKey` (não dois `buildPlazaListHref`).
- `hasActiveFilters` inclui texto local de busca (Limpar visível antes do debounce).
- `clearDebounce` duplicado no `onSubmit` removido.
- Impeccable `detect.mjs` no alvo: 0 findings (sem critique formal nesta sessão).
## Adiado com gatilho
- **Sync `search` local ↔ `state.q` no back/forward do browser.** `key={buildPlazaFiltersKey(state)}` remonta na maioria dos casos; edge case: campo pode mostrar texto stale após voltar/avançar. **Gatilho:** QA ou R6 reportar desync em `/campanha/pracas`.
- **Shell compartilhado de pending UI** (`data-pending` + `aria-busy` + `aria-live` "Atualizando resultados…"). Hoje só `ActionPlanFilters` + `PlazaFilters` (`SupporterFilters` ainda sem pending). **Gatilho:** 3ª lista de campanha com o mesmo padrão pending, ou R6/critique citar duplicação em ≥2 superfícies.
- **Mesmo padrão em `SupporterFilters` (e outras listas com Buscar).** Revisitar quando produto pedir ou quando R6/critique citar “Buscar” como atrito repetido em ≥2 listas.
- **Extrair `useDebouncedSearchParam`.** Revisitar no 3º call site idêntico (Praças + apoiadores + 1).

View file

@ -1,6 +1,6 @@
# Roadmap — Teqo
Atualizado em: 2026-07-21 (B9 + B10 entregues; débitos B9 → A9+/C8 F4; débitos B10 → B6)
Atualizado em: 2026-07-21 (fill-in filtros-auto entregue em código; B9 + B10 entregues; débitos B9 → A9+/C8 F4; débitos B10 → B6)
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).
@ -81,11 +81,11 @@ flowchart TD
C2prod --> C5["C5 GOTV (validar)"]
```
Paralelizáveis agora: **B11**, fill-ins da lista (filtros-auto, A9+, …). ~~**A9** / **B9** / **B7** / **B10**~~ entregues 2026-07-21. **B6** absorve o hot path de hover pós-B10 (Janela 3 / gatilho de densidade).
Paralelizáveis agora: **B11**, fill-ins da lista (A9+, …). ~~**A9** / **B9** / **B7** / **B10** / **filtros-auto**~~ entregues 2026-07-21. **B6** absorve o hot path de hover pós-B10 (Janela 3 / gatilho de densidade).
### Sequência por janela
**Janela 1 — agora → 05/08 (convenções):** ~~R0 → R1 → R2~~ entregues; ~~**A9** estimativa de votos~~ / ~~**B9** edição rápida na lista~~ / ~~**B7** mapa filtrado~~ / ~~**B10** hover+click-nav~~ entregues; **deploy da remodelagem** (revisar SQL destrutivo da migração antes do build) + smoke em produção; **B11** escala % dos válidos; R6 critique/polish; Onda 0 jurídica em paralelo.
**Janela 1 — agora → 05/08 (convenções):** ~~R0 → R1 → R2~~ entregues; ~~**A9** estimativa de votos~~ / ~~**B9** edição rápida na lista~~ / ~~**B7** mapa filtrado~~ / ~~**B10** hover+click-nav~~ / ~~**filtros-auto** lista de Praças~~ entregues; **deploy da remodelagem** (revisar SQL destrutivo da migração antes do build) + smoke em produção; **B11** escala % dos válidos; R6 critique/polish; Onda 0 jurídica em paralelo.
**Janela 2 — 05/08 → 16/08 (pré-propaganda):** C2 dados reais assim que o jurídico liberar; D2 se sobrar folga.
@ -108,6 +108,7 @@ Paralelizáveis agora: **B11**, fill-ins da lista (filtros-auto, A9+, …). ~~**
- **B7 (2026-07-21)** — **Mapa das Praças filtrado pela lista** (`buildPlazaListWhere` em `loadPlazaMapBundle`; `rawSearchParams` na página; empty → omitir painel; int `plazaMapData.int.spec.ts`) — [plano](plans/mapa-pracas-filtrado.md).
- **B9 (2026-07-21)** — **Edição rápida na lista de Praças** (Assessores / Tendência / `expectedVotes` via Popovers em `PlazaList*Control`; `listFormActions`; sem migration) — [plano](plans/edicao-rapida-lista-pracas.md). Débitos `/simplify`: **A9+** F2 (revalidate) + **C8** F4 (DRY formActions).
- **B10 (2026-07-21)** — **Hover/tap no Mapa das Praças** (destaque + `MapFeatureReadout`; desktop click navega; mobile 2º tap; SSA/CMS N>1 → `zoneBreakdown`; `plazasByIbgeCode` / `resolvePlazaMapNavigation`) — [plano](plans/hover-mapa-pracas.md). Débito perf hover O(n) → **B6** [escala-dry-pos-b3.md](plans/escala-dry-pos-b3.md).
- **Fill-in filtros-auto (2026-07-21)** — **Filtros auto-aplicados na lista de Praças** (`PlazaFilters`: debounce 1s no `q`, Enter imediato, selects imediatos, remove Buscar; `useTransition` + pending a11y; `shouldUpdatePlazaSearchUrl` + no-op via `buildPlazaFiltersKey`; sem migration) — [plano](plans/filtros-auto-pracas.md). Débitos pós-`/simplify`: sync back/forward `search`↔`state.q` e shell pending compartilhado — gatilhos no plano.
## Supersedidos pela remodelagem (2026-07-20)
@ -135,7 +136,7 @@ Paralelizáveis agora: **B11**, fill-ins da lista (filtros-auto, A9+, …). ~~**
- **C5** operação dia D / GOTV _(validar com produto)_ · design [`Dia-D-GOTV`](design-refs/latest/Dia-D-GOTV.png) · depende de C2 dados reais
- **D2** push + sino in-app · soft: chave `campanha-notificacoes-push` (Onda 0) · [plano](plans/notifications.md)
- **R6** critique/polish visual da vertical remodelada (ciclo /impeccable completo por superfície; smoke visual coordenador feito em 2026-07-21) · gatilho: antes de 16/08 · (opcional: mover filtros acima do mapa)
- **Fill-ins:** filtros auto na lista de Praças (debounce 1s no `q`, selects já imediatos; remove Buscar) · [plano](plans/filtros-auto-pracas.md) · **A9+** loader compartilhado lista/mapa + revalidate escopada pós-B9 ([plano](plans/escala-dry-pos-a9.md)) · **C8** F4 DRY `listFormActions`↔`/editar` ([plano](plans/escala-dry-pos-c6.md)) · O0+ ([plano](plans/escala-dry-pos-onda0.md)) · RS+ auth read leve + shells de senha ([plano](plans/escala-dry-pos-reset-senha-perfil.md)) · higiene PascalCase
- **Fill-ins:** **A9+** loader compartilhado lista/mapa + revalidate escopada pós-B9 ([plano](plans/escala-dry-pos-a9.md)) · **C8** F4 DRY `listFormActions`↔`/editar` ([plano](plans/escala-dry-pos-c6.md)) · O0+ ([plano](plans/escala-dry-pos-onda0.md)) · RS+ auth read leve + shells de senha ([plano](plans/escala-dry-pos-reset-senha-perfil.md)) · higiene PascalCase
## Bloqueadores atuais

View file

@ -1,21 +1,26 @@
'use client'
import { useRouter } from 'next/navigation'
import { useState } from 'react'
import { useEffect, useRef, useState, useTransition } from 'react'
import { CampaignSearchInput } from '@/components/campaign/CampaignSearchInput'
import { Button } from '@/components/ui/button'
import { Field, FieldLabel } from '@/components/ui/field'
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
import { bahiaIdentityTerritories } from '@/lib/bahiaTerritories'
import { normalizedText } from '@/utilities/campaignListUrl'
import {
buildPlazaFiltersKey,
buildPlazaListHref,
plazaKindLabels,
plazaListCoverageLabels,
politicalTrendLabels,
shouldUpdatePlazaSearchUrl,
type PlazaListState,
} from '@/utilities/plazaUi'
const SEARCH_DEBOUNCE_MS = 1000
type PlazaFiltersProps = {
state: PlazaListState
showStaffFilters: boolean
@ -24,31 +29,83 @@ type PlazaFiltersProps = {
export const PlazaFilters = ({ state, showStaffFilters }: PlazaFiltersProps) => {
const router = useRouter()
const [search, setSearch] = useState(state.q ?? '')
const [isPending, startTransition] = useTransition()
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const navigate = (next: PlazaListState) => {
router.replace(buildPlazaListHref({ ...next, page: 1 }, 1), { scroll: false })
useEffect(
() => () => {
if (debounceRef.current) clearTimeout(debounceRef.current)
},
[],
)
const clearDebounce = () => {
if (debounceRef.current) {
clearTimeout(debounceRef.current)
debounceRef.current = null
}
}
const commitNavigation = (patch: Partial<PlazaListState>) => {
clearDebounce()
const merged: PlazaListState = {
...state,
...patch,
page: 1,
q: normalizedText(patch.q !== undefined ? patch.q : search),
}
if (buildPlazaFiltersKey(merged) === buildPlazaFiltersKey({ ...state, page: 1 })) return
startTransition(() => {
router.replace(buildPlazaListHref(merged, 1), { scroll: false })
})
}
const scheduleSearchNavigation = (value: string) => {
clearDebounce()
if (!shouldUpdatePlazaSearchUrl(value, state.q)) return
debounceRef.current = setTimeout(() => {
debounceRef.current = null
commitNavigation({ q: value })
}, SEARCH_DEBOUNCE_MS)
}
const hasActiveFilters = Boolean(
state.q || state.region || state.kind || state.coverage || state.priority || state.trend,
state.q ||
normalizedText(search) ||
state.region ||
state.kind ||
state.coverage ||
state.priority ||
state.trend,
)
return (
<form
role="search"
className="flex flex-col gap-3"
className="flex flex-col gap-3 transition-opacity data-[pending=true]:opacity-70"
data-pending={isPending}
aria-busy={isPending}
onSubmit={(event) => {
event.preventDefault()
navigate({ ...state, q: search.trim() || undefined })
commitNavigation({ q: search })
}}
>
<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="plaza-search"
label="Buscar Praça"
placeholder="Buscar por município ou zona…"
value={search}
onChange={(event) => setSearch(event.target.value)}
onChange={(event) => {
const value = event.target.value
setSearch(value)
scheduleSearchNavigation(value)
}}
/>
<Field className="md:w-56">
<FieldLabel htmlFor="plaza-filter-region">Território de identidade</FieldLabel>
@ -56,8 +113,7 @@ export const PlazaFilters = ({ state, showStaffFilters }: PlazaFiltersProps) =>
id="plaza-filter-region"
value={state.region ?? ''}
onChange={(event) =>
navigate({
...state,
commitNavigation({
region: (event.target.value || undefined) as PlazaListState['region'],
})
}
@ -77,8 +133,7 @@ export const PlazaFilters = ({ state, showStaffFilters }: PlazaFiltersProps) =>
id="plaza-filter-kind"
value={state.kind ?? ''}
onChange={(event) =>
navigate({
...state,
commitNavigation({
kind: (event.target.value || undefined) as PlazaListState['kind'],
})
}
@ -100,8 +155,7 @@ export const PlazaFilters = ({ state, showStaffFilters }: PlazaFiltersProps) =>
id="plaza-filter-coverage"
value={state.coverage ?? ''}
onChange={(event) =>
navigate({
...state,
commitNavigation({
coverage: (event.target.value || undefined) as PlazaListState['coverage'],
})
}
@ -125,8 +179,7 @@ export const PlazaFilters = ({ state, showStaffFilters }: PlazaFiltersProps) =>
id="plaza-filter-trend"
value={state.trend ?? ''}
onChange={(event) =>
navigate({
...state,
commitNavigation({
trend: (event.target.value || undefined) as PlazaListState['trend'],
})
}
@ -148,8 +201,7 @@ export const PlazaFilters = ({ state, showStaffFilters }: PlazaFiltersProps) =>
id="plaza-filter-priority"
value={state.priority ?? ''}
onChange={(event) =>
navigate({
...state,
commitNavigation({
priority: event.target.value === 'alta' ? 'alta' : undefined,
})
}
@ -161,24 +213,24 @@ export const PlazaFilters = ({ state, showStaffFilters }: PlazaFiltersProps) =>
</Field>
</>
) : null}
<div className="flex gap-2">
<Button type="submit" variant="secondary" className="min-h-11">
Buscar
</Button>
{hasActiveFilters ? (
{hasActiveFilters ? (
<div className="flex shrink-0 gap-2 md:self-end">
<Button
type="button"
variant="ghost"
className="min-h-11"
onClick={() => {
clearDebounce()
setSearch('')
router.replace('/campanha/pracas', { scroll: false })
startTransition(() => {
router.replace('/campanha/pracas', { scroll: false })
})
}}
>
Limpar
</Button>
) : null}
</div>
</div>
) : null}
</div>
</form>
)

View file

@ -158,6 +158,11 @@ export const buildPlazaFiltersKey = (state: PlazaListState): string =>
export const buildPlazaListHref = (state: PlazaListState, page: number): string =>
buildListHref(state, buildPlazaListSearchParams, '/campanha/pracas', page)
export const shouldUpdatePlazaSearchUrl = (
input: string,
currentQ: string | undefined,
): boolean => normalizedText(input) !== currentQ
export const resolvePlazaListUrl = (
params: PlazaListSearchParams,
totalPages?: number,

View file

@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest'
import { shouldUpdatePlazaSearchUrl } from '@/utilities/plazaUi'
describe('shouldUpdatePlazaSearchUrl', () => {
it('returns false when canonical q matches the current URL q', () => {
expect(shouldUpdatePlazaSearchUrl('salvador', 'salvador')).toBe(false)
expect(shouldUpdatePlazaSearchUrl(' salvador ', 'salvador')).toBe(false)
})
it('returns true when q differs or is newly set', () => {
expect(shouldUpdatePlazaSearchUrl('salv', 'salvador')).toBe(true)
expect(shouldUpdatePlazaSearchUrl('salvador', undefined)).toBe(true)
expect(shouldUpdatePlazaSearchUrl('', 'salvador')).toBe(true)
})
})