22 KiB
Implementation Plan: Filtro de Busca Avançada — FilterSidebar
Branch: 024-filtro-busca-avancada | Date: 2026-04-20 | Spec: spec.md
Input: Feature specification from /specs/024-filtro-busca-avancada/spec.md
Summary
Enriquecer os endpoints de catálogo existentes com o campo property_count (COUNT dinâmico via subquery SQLAlchemy, sem migration) e reformular o FilterSidebar.tsx com três melhorias de UX: (1) campo de busca cross-categoria com debounce 200 ms e sugestões agrupadas inline, (2) estado inicial controlado com apenas a seção "Preço" aberta e auto-expansão das seções que contêm filtros ativos da URL, e (3) truncamento das listas (top-5 visíveis + "Ver mais") com ordenação por popularidade e badge "Popular" nos 3 mais populares. Sem novas tabelas, sem novos endpoints, sem novas páginas, sem alteração de rotas.
Technical Context
Language/Version: Python 3.12 (backend) · TypeScript 5.5 (frontend)
Primary Dependencies: Flask 3.x, SQLAlchemy 2.x, Pydantic v2 (backend) · React 18, Tailwind CSS 3.4, Axios (frontend)
Storage: PostgreSQL 16 — sem novas tabelas ou migrations (property_count é calculado via func.count + outerjoin no ORM, não persistido)
Testing: pytest (backend — testes de integração nos endpoints enriquecidos)
Target Platform: Browser SPA (desktop); Linux server via Docker
Project Type: web-service (Flask REST API) + SPA (React)
Performance Goals: busca cross-categoria < 50 ms (processamento local, NFR-001); endpoints de catálogo < 300 ms p95 (COUNT em tabelas pequenas, < 5 k registros)
Constraints: sem nova dependência npm ou Python; sem rotas novas; filtros mobile fora de escopo; estado de expansão das seções não persistido em localStorage (NFR per spec)
Scale/Scope: 3 schemas Pydantic editados, 2 rotas Flask editadas, 1 componente React reformulado (~600 linhas → ~800 linhas), 2 arquivos de tipos TypeScript editados
Constitution Check
GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.
| Princípio | Status | Observação |
|---|---|---|
| I. Design-First | ✅ PASS | Campo de busca, badge "Popular" e botão "Ver mais" usam exclusivamente tokens do DESIGN.md: textTertiary, textSecondary, borderSubtle, borderStandard, surface, brand; animação duration-200 ease-out do CSS grid trick existente é reutilizada |
| II. Separation of Concerns | ✅ PASS | Backend calcula e expõe property_count em JSON; toda lógica de busca, ordenação, truncamento e expansão de seções ocorre no cliente; Flask não renderiza HTML |
| III. Spec-Driven | ✅ PASS | spec.md aprovado com user stories P1/P2 e acceptance scenarios; este plano é derivado do spec |
| IV. Data Integrity | ✅ PASS | property_count é campo somente-leitura calculado por COUNT (não alterável via API); Pydantic v2 declara property_count: int = 0 com default; NFR-005: COUNT filtra is_active == True |
| V. Security | ✅ PASS | NFR-005: contagem exclui imóveis inativos; nenhum dado privado exposto; campo é adicional somente-leitura nos endpoints públicos já existentes |
| VI. Simplicity First | ✅ PASS | Debounce manual via useEffect/setTimeout (sem lodash); normalização nativa String.normalize('NFD'); sem nova biblioteca; estado de seções em Record<string, boolean> simples; COUNT via subquery SQLAlchemy sem hybrid property ou stored procedure |
Veredicto: Sem violações. Pode prosseguir para Phase 0.
Re-check pós-design (Phase 1): ✅ Confirmado — nenhuma abstração prematura introduzida; property_count via subquery é o mecanismo mais simples que atende NFR-005 sem migration.
Project Structure
Documentation (this feature)
specs/024-filtro-busca-avancada/
├── plan.md ← Este arquivo
├── research.md ← Phase 0 output
├── data-model.md ← Phase 1 output
├── quickstart.md ← Phase 1 output
├── contracts/
│ └── api-catalog-enhancements.md ← Phase 1 output
└── tasks.md ← Phase 2 output (/speckit.tasks — NÃO gerado aqui)
Source Code (repository root)
backend/
└── app/
├── schemas/
│ └── catalog.py ← EDITADO — property_count: int = 0 em PropertyTypeOut, CityOut, NeighborhoodOut
└── routes/
├── catalog.py ← EDITADO — subquery COUNT em list_property_types()
└── locations.py ← EDITADO — subquery COUNT em list_cities() e list_neighborhoods()
frontend/
└── src/
├── types/
│ └── catalog.ts ← EDITADO — property_count?: number em PropertyType, City, Neighborhood
└── components/
└── FilterSidebar.tsx ← EDITADO (principal — ~200 linhas adicionadas)
Structure Decision: Projeto web full-stack (Option 2). Sem novos arquivos — apenas edições cirúrgicas em arquivos existentes. Toda a lógica nova de sidebar fica contida em FilterSidebar.tsx (sub-componentes locais); nenhum hook ou serviço separado é criado porque a lógica não é compartilhada com outros componentes (YAGNI).
Complexity Tracking
Nenhuma violação de Constitution detectada. Seção não aplicável.
Architecture & Data Flow
Fluxo de dados: backend → frontend
PostgreSQL
└─ properties (is_active=TRUE) ──COUNT──┐
↓
Flask routes subquery via SQLAlchemy func.count + outerjoin
├─ GET /api/v1/cities → CityOut[] (+ property_count)
├─ GET /api/v1/neighborhoods → NeighborhoodOut[] (+ property_count)
└─ GET /api/v1/property-types → PropertyTypeOut[] (subtypes + property_count)
↓
catalog.ts getCities(), getNeighborhoods(), getPropertyTypes()
↓
FilterSidebar.tsx props cities[], neighborhoods[], propertyTypes[]
├─ ordena por property_count DESC (localmente)
├─ mostra top-5, botão "Ver mais (N)" se > 5
├─ badge "Popular" nos 3 primeiros (index < 3)
└─ item selecionado sempre visível (promoted ao topo se oculto)
Fluxo de dados: busca cross-categoria
[usuário digita no campo "Buscar filtro…"]
↓
filterSearch (state) ── debounce 200ms ──→ searchQuery (state)
↓
searchQuery !== '' ?
├─ YES → computeSuggestions(searchQuery, propertyTypes, cities, neighborhoods, amenities)
│ normaliza (NFD, lowercase, sem acento)
│ → FilterSuggestion[] agrupados por category
│ → <SuggestionList> inline sob o campo
└─ NO → renderização normal das seções accordion
↓
[clique em sugestão]
├─ set({ [filterKey]: value, page: 1 }) (aplica filtro)
├─ expandSection(sectionKey) (abre seção relevante)
└─ setFilterSearch('') (limpa campo)
Fluxo de dados: estado de expansão das seções
URL params (filters.city_id, filters.subtype_id, …) ─→ initOpenSections(filters)
↓
openSections: Record<SectionKey, boolean>
{
imobiliaria: false,
localizacao: filters.city_id != null || filters.neighborhood_id != null,
tipo: filters.subtype_id != null,
preco: true, ← sempre aberta por padrão
quartos: filters.bedrooms_min != null || …,
area: filters.area_min != null || filters.area_max != null,
comodidades: (filters.amenity_ids?.length ?? 0) > 0,
}
↓
Section recebe `open={openSections[key]}` + `onToggle={() => toggleSection(key)}`
(Section passa para controlled mode, mantendo uncontrolled como fallback)
Components Affected
Backend
| Arquivo | Tipo | Mudança |
|---|---|---|
backend/app/schemas/catalog.py |
EDIT | Adicionar property_count: int = 0 em PropertyTypeOut, CityOut, NeighborhoodOut |
backend/app/routes/catalog.py |
EDIT | list_property_types(): calcular property_count por subtype via subquery COUNT; injetar no dict antes de serializar |
backend/app/routes/locations.py |
EDIT | list_cities(): query com outerjoin(Property) + func.count + group_by; list_neighborhoods(): idem |
Frontend
| Arquivo | Tipo | Mudança |
|---|---|---|
frontend/src/types/catalog.ts |
EDIT | Adicionar property_count?: number em PropertyType, City, Neighborhood |
frontend/src/components/FilterSidebar.tsx |
EDIT | Reformulação principal — ver detalhes abaixo |
FilterSidebar.tsx — mudanças internas
| Sub-componente / Lógica | Status | Descrição |
|---|---|---|
Section |
EDIT | Suporte a open?: boolean + onToggle?: () => void (controlled mode); mantém useState(defaultOpen) como fallback quando open não é passado |
SidebarSearchInput |
NEW (local) | <input> com ícone de lupa, placeholder "Buscar filtro…", desabilitado quando catalogLoading |
SuggestionList |
NEW (local) | Lista inline de FilterSuggestion[] agrupados, com navegação por teclado (↑↓ Enter Escape) |
PopularBadge |
NEW (local) | <span>Popular</span> com tokens brand/20 bg e brand text, text-[10px] |
TruncatedFilterList |
NEW (local) | Wrapper que exibe top-5 + botão "Ver mais (N)" / "Ver menos"; garante visibilidade de item selecionado |
computeSuggestions() |
NEW (função local) | Normaliza query, filtra todos os itens de catálogo, retorna FilterSuggestion[] |
initOpenSections() |
NEW (função local) | Deriva Record<SectionKey, boolean> a partir de PropertyFilters — seção Preço sempre true |
openSections state |
NEW | useState<Record<SectionKey, boolean>> inicializado via initOpenSections(filters) |
filterSearch / searchQuery state |
NEW | useState<string> para input + estado debounced |
| Seção "Imobiliária" | EDIT | defaultOpen={false} → controlled via openSections |
| Seção "Localização" | EDIT | defaultOpen condicional → controlled via openSections |
| Seção "Tipo de imóvel" | EDIT | idem + TruncatedFilterList nos subtypes de cada categoria |
| Seção "Preço" | EDIT | defaultOpen={true} já existe → agora controlled via openSections.preco = true |
| Seção "Quartos e vagas" | EDIT | controlled via openSections |
| Seção "Área" | EDIT | controlled via openSections |
| Seção "Comodidades" | EDIT | controlled via openSections; TruncatedFilterList por grupo |
Technical Decisions
TD-001: property_count via subquery dinâmica (sem migration, sem hybrid property)
Decisão: Calcular property_count nos route handlers via db.session.query(City, func.count(Property.id)).outerjoin(…).group_by(City.id). Adicionar property_count: int = 0 aos schemas Pydantic com default 0.
Rationale: Evita migration desnecessária (Constitution IV). property_count é dado de leitura; persistir seria denormalização sem benefício real dado o volume (< 5 k imóveis). Subquery em tabelas pequenas é negligenciável em performance.
Alternativas descartadas:
- SQLAlchemy
column_propertycom correlated subquery: mais limpo no ORM mas adiciona complexidade ao modelo sem ganho real (usado uma vez). - Coluna persistida com trigger: over-engineering (Constitution VI); requer migration + lógica de atualização.
Impacto na serialização: Os routes handlers passam a construir dicts manualmente para City/Neighborhood. Para PropertyType (hierárquico), o property_count é injetado nos subtypes após serialização com model_dump() | {'property_count': count_map.get(sub.id, 0)}.
TD-002: Section em controlled mode com fallback uncontrolled
Decisão: Estender Section para aceitar props opcionais open?: boolean e onToggle?: () => void. Quando open é definido, o componente é controlled; caso contrário mantém useState(defaultOpen) atual.
Rationale: Backward-compatible — nenhum caller externo é quebrado. O FilterSidebar passa a controlar o estado de todas as seções via openSections. A busca cross-categoria pode então expandir a seção relevante via expandSection(key) sem lógica especial.
Impacto: Apenas Section dentro de FilterSidebar.tsx é afetado; Section não é exportado.
TD-003: Debounce manual (sem lodash/use-debounce)
Decisão: Implementar debounce 200 ms via useEffect(() => { const t = setTimeout(..., 200); return () => clearTimeout(t); }, [filterSearch]).
Rationale: NFR-001 exige processamento local. Adicionar lodash ou use-debounce só para isso viola Constitution VI (YAGNI). O padrão useEffect + setTimeout é idiomático em React e sem dependência extra.
TD-004: Normalização de texto sem biblioteca
Decisão: text.normalize('NFD').replace(/\p{Mn}/gu, '').toLowerCase().
Rationale: Cobre 100% dos casos do spec (acentos, cedilha — FR-005) sem adicionar dependência. Suportado em todos os browsers modernos e Node 18+.
TD-005: Badge "Popular" — top-3 por seção após ordenação
Decisão: Após ordenar items por property_count DESC no frontend, aplicar badge nos itens com index < 3 (os 3 primeiros). A lógica reside em TruncatedFilterList.
Rationale: Spec FR-014. Simples e correto. O backend já envia ordenado; o frontend apenas exibe badge nos primeiros três.
TD-006: Item selecionado sempre visível no truncamento
Decisão: Antes de exibir top-5, verificar se o item ativo (ex.: filters.subtype_id) está entre os 5 primeiros. Se não estiver, promovê-lo para o início da lista (sem alterar a ordem geral) garantindo que apareça sem "Ver mais". Spec FR-015.
Rationale: Evita confusão do usuário que selecionou um filtro mas não o vê no sidebar. Promoção temporária é local e não altera dados.
Diagram of Changes
┌─────────────────────────────────────────────────────────────────┐
│ BACKEND │
│ │
│ schemas/catalog.py │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ PropertyTypeOut + property_count: int = 0 │ │
│ │ CityOut + property_count: int = 0 │ │
│ │ NeighborhoodOut + property_count: int = 0 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ routes/locations.py │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ list_cities() │ │
│ │ query(City, func.count(Property.id)) │ │
│ │ .outerjoin(Property.city_id == City.id │ │
│ │ & Property.is_active == True) │ │
│ │ .group_by(City.id) │ │
│ │ │ │
│ │ list_neighborhoods() [mesma lógica] │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ routes/catalog.py │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ list_property_types() │ │
│ │ count_map: dict[int, int] ← subquery por subtype_id │ │
│ │ injeta property_count em cada subtype dict │ │
│ └─────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
│ JSON (enriquecido)
▼
┌─────────────────────────────────────────────────────────────────┐
│ FRONTEND │
│ │
│ types/catalog.ts │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ PropertyType + property_count?: number │ │
│ │ City + property_count?: number │ │
│ │ Neighborhood + property_count?: number │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ FilterSidebar.tsx │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ NOVOS estados: │ │
│ │ filterSearch: string │ │
│ │ searchQuery: string (debounced) │ │
│ │ openSections: Record<SectionKey, boolean> │ │
│ │ │ │
│ │ NOVOS componentes locais: │ │
│ │ SidebarSearchInput ← campo "Buscar filtro…" │ │
│ │ SuggestionList ← sugestões agrupadas inline │ │
│ │ TruncatedFilterList ← top-5 + "Ver mais" + badge │ │
│ │ PopularBadge ← badge "Popular" (brand token) │ │
│ │ │ │
│ │ NOVAS funções locais: │ │
│ │ computeSuggestions() ← normaliza + filtra catálogo │ │
│ │ initOpenSections() ← deriva estado de URL filters │ │
│ │ │ │
│ │ EDITADOS: │ │
│ │ Section ← controlled mode opcional │ │
│ │ Todas as seções ← usam openSections │ │
│ │ Seções com listas ← usam TruncatedFilterList │ │
│ └─────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
Edge Cases & Mitigations
| Edge case (spec) | Mitigação implementada |
|---|---|
| Mesmo nome em cidade e bairro (ex.: "Santos") | FilterSuggestion.filterKey distingue city_id vs neighborhood_id; ambos aparecem em grupos diferentes na SuggestionList |
| Busca com acentos/cedilha/hífen | normalize('NFD') + replace(/\p{Mn}/gu, '') no query e nos labels antes da comparação |
| Filtro selecionado removido do catálogo | O item ativo não aparece na lista mas o badge de contagem no Section continua mostrando 1; o filtro permanece aplicado até o usuário limpar — comportamento existente, não alterado |
| Item selecionado entre os ocultos no "Ver mais" | TruncatedFilterList promove item ativo ao topo quando showAll = false; sempre visível sem precisar expandir |
Dados de property_count ainda carregando |
catalogLoading = true → SidebarSearchInput desabilitado (FR-007); listas sem badge/ordenação especial até dados chegarem |
Seção de preço com listing_type mudando |
openSections é inicializado uma vez (no mount); mudança de listing_type não recolapsa seção Preço — comportamento correto per spec |
Campo de busca quando catalogLoading |
disabled + opacity-50 cursor-not-allowed via Tailwind; nenhuma sugestão computada |
Out of Scope (confirmed from spec)
- Filtros mobile (sheet/modal) — feature separada
- Histórico ou salvamento de filtros
- Busca por imóveis individuais (código, endereço) — coberto pela feature 023
- Badge "Popular" em comodidades (amenities não têm
property_count— a contagem seria N:N e menos relevante; a spec cobre tipos, cidades e bairros) - Paginação de sugestões
- Internacionalização de labels