# 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**: ```python # 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**: ```tsx 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: ```tsx 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. ```ts 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**: ```ts 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). ```ts function getVisibleItems( 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.