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

177 lines
7.2 KiB
Markdown

# 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 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; 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<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 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.