feat: add full project - backend, frontend, docker, specs and configs
This commit is contained in:
parent
b77c7d5a01
commit
e6cb06255b
24489 changed files with 61341 additions and 36 deletions
177
specs/024-filtro-busca-avancada/research.md
Normal file
177
specs/024-filtro-busca-avancada/research.md
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
# 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<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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue