sass-imobiliaria/specs/024-filtro-busca-avancada/research.md

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_property SQLAlchemy com correlated subquery: mais elegante no modelo, mas acopla lógica de negócio ao ORM model; requer importação de Property em catalog.py (circular import risk). Descartado.
  • Coluna property_count INTEGER persistida: 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 retornem count = 0 em vez de serem omitidas.
  • func.count(Property.id) conta apenas linhas não-nulas (imóveis existentes), diferente de func.count('*').
  • & (Property.is_active.is_(True)) na condição do join (não no WHERE) garante que cidades sem imóveis ativos retornem 0, 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 = 0 no 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 !== undefined como discriminador é mais robusto que verificar se onToggle está definido.