7.2 KiB
Research: Filtro de Busca Avançada — FilterSidebar
Feature: 024-filtro-busca-avancada | Phase: 0 | Date: 2026-04-20
Unknowns Resolved
R-001: Como calcular property_count sem migration?
Decision: Subquery dinâmica no route handler via func.count(Property.id) + outerjoin + group_by.
Rationale: Os endpoints de catálogo já existem e têm baixo volume de dados (< 50 cidades, < 200 bairros, < 30 tipos). Um COUNT em outerjoin com filtro is_active=True é instantâneo. Não há necessidade de persistir o valor: mudaria a cada imóvel inserido/removido, exigindo triggers ou re-sincronização.
Alternatives considered:
column_propertySQLAlchemy com correlated subquery: mais elegante no modelo, mas acopla lógica de negócio ao ORM model; requer importação dePropertyemcatalog.py(circular import risk). Descartado.- Coluna
property_count INTEGERpersistida: requer migration Alembic + lógica de atualização (trigger ou chamada explícita). Over-engineering para < 5 k imóveis. Descartado. - Endpoint separado
GET /api/v1/catalog-stats: cria endpoint extra sem necessidade; o cliente faria duas chamadas para montar o sidebar. Descartado (NFR-001 prefere menos chamadas).
Implementation pattern:
# locations.py — list_cities()
from sqlalchemy import func
from app.models.property import Property
rows = (
db.session.query(City, func.count(Property.id).label("cnt"))
.outerjoin(
Property,
(Property.city_id == City.id) & (Property.is_active.is_(True))
)
.group_by(City.id)
.order_by(City.state, City.name)
.all()
)
return jsonify([
{**CityOut.model_validate(city).model_dump(), "property_count": cnt}
for city, cnt in rows
])
Circular import: Property é importado nos routes, não no model catalog.py/location.py — sem risco.
R-002: Como tornar o componente Section controlável externamente?
Decision: Adicionar props opcionais open?: boolean e onToggle?: () => void. Quando open é undefined, manter comportamento atual com useState(defaultOpen) (uncontrolled). Quando open é passado, ignorar o state interno e usar o prop.
Rationale: Backward-compatible — nenhum chamador atual do Section (dentro de FilterSidebar.tsx) precisa ser alterado para continuar funcionando; só as seções que precisam de controle externo recebem open + onToggle. Solução mais simples que refatorar para controlled-only.
Pattern:
function Section({
title, badge, children, defaultOpen = true,
open: controlledOpen, onToggle,
}: {
title: string; badge?: number; children: React.ReactNode
defaultOpen?: boolean; open?: boolean; onToggle?: () => void
}) {
const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen)
const isControlled = controlledOpen !== undefined
const open = isControlled ? controlledOpen : uncontrolledOpen
const handleToggle = () => {
if (isControlled) onToggle?.()
else setUncontrolledOpen(v => !v)
}
// ... resto igual
}
R-003: Estratégia de debounce sem biblioteca?
Decision: useEffect com setTimeout/clearTimeout — padrão idiomático em React.
Rationale: Adicionar lodash ou use-debounce apenas para um setTimeout de 200 ms viola Constitution VI (YAGNI). O padrão abaixo é well-known, testável e zero-dependency:
const [filterSearch, setFilterSearch] = useState('')
const [searchQuery, setSearchQuery] = useState('')
useEffect(() => {
const t = setTimeout(() => setSearchQuery(filterSearch), 200)
return () => clearTimeout(t)
}, [filterSearch])
R-004: Normalização de texto para busca cross-categoria?
Decision: String.prototype.normalize('NFD') + regex Unicode property escape para remover diacríticos.
Rationale: Cobre todos os casos do FR-005 (acentos, cedilha). Suportado em todos os browsers modernos (Chrome 64+, Firefox 78+, Safari 12+) e Node 18+. Zero dependência.
function normalizeText(s: string): string {
return s.normalize('NFD').replace(/\p{Mn}/gu, '').toLowerCase()
}
Para hífens/espaços: a comparação includes() cobre casos como "São Paulo" → "sao paulo".includes("sao paulo").
R-005: Como estruturar FilterSuggestion para cobrir todos os tipos de filtro?
Decision:
type SectionKey = 'imobiliaria' | 'localizacao' | 'tipo' | 'preco' | 'quartos' | 'area' | 'comodidades'
interface FilterSuggestion {
category: string // label do grupo (ex.: "Tipo de imóvel")
sectionKey: SectionKey // para expandir a seção correta
label: string // texto exibido na sugestão
filterKey: keyof PropertyFilters
value: number | string | undefined
}
Rationale: sectionKey e filterKey são separados porque o mesmo filterKey (city_id) pertence à seção localizacao. Ter ambos permite expandSection(sectionKey) e set({ [filterKey]: value }) independentemente.
Cobertura por categoria:
| Categoria | filterKey |
sectionKey |
|---|---|---|
| Tipo de imóvel | subtype_id |
tipo |
| Cidade | city_id |
localizacao |
| Bairro | neighborhood_id |
localizacao |
| Comodidade | n/a (toggle em amenity_ids) |
comodidades |
R-006: Como garantir que item selecionado seja visível no truncamento?
Decision: "Promoção ao topo" — antes de fatiar items.slice(0, 5), verificar se o item ativo está entre os primeiros 5. Se não, movê-lo para a primeira posição na lista truncada (sem alterar items original).
function getVisibleItems<T extends { id: number }>(
items: T[],
activeId: number | undefined,
showAll: boolean,
limit = 5,
): T[] {
if (showAll) return items
const top = items.slice(0, limit)
if (activeId == null || top.some(i => i.id === activeId)) return top
const active = items.find(i => i.id === activeId)
if (!active) return top
return [active, ...top.slice(0, limit - 1)]
}
Rationale: Simples, sem efeitos colaterais no estado global. O usuário sempre vê o item que selecionou, mesmo após reabrir a seção.
Best Practices Confirmed
SQLAlchemy func.count com outerjoin
outerjoin(LEFT OUTER JOIN) garante que entidades sem imóveis retornemcount = 0em vez de serem omitidas.func.count(Property.id)conta apenas linhas não-nulas (imóveis existentes), diferente defunc.count('*').& (Property.is_active.is_(True))na condição do join (não no WHERE) garante que cidades sem imóveis ativos retornem0, em vez de serem filtradas.
Pydantic v2 model_dump() + dict merge
CityOut.model_validate(city).model_dump() | {'property_count': cnt}produz dict Python puro válido.jsonify()do Flask aceita dicts Python diretamente.- O campo
property_count: int = 0no schema garante que se o merge falhar silenciosamente, o valor default é0(não None).
React controlled vs uncontrolled components
- O padrão de controlled mode com fallback uncontrolled é documentado na RFC do React e é considerado backward-compatible.
- Usar
open !== undefinedcomo discriminador é mais robusto que verificar seonToggleestá definido.