feat: add full project - backend, frontend, docker, specs and configs

This commit is contained in:
MatheusAlves96 2026-04-20 23:59:45 -03:00
parent b77c7d5a01
commit e6cb06255b
24489 changed files with 61341 additions and 36 deletions

View file

@ -0,0 +1,169 @@
# API Catalog Enhancements — Contrato de Interface
**Feature**: `024-filtro-busca-avancada`
**Tipo de mudança**: Adição de campo somente-leitura em endpoints existentes (backward-compatible)
**Versão da API**: `/api/v1` (sem mudança de versão — campo adicional não quebra clientes existentes)
---
## Resumo das Mudanças
Três endpoints existentes passam a incluir o campo `property_count` na resposta. Nenhum endpoint novo é criado. Nenhum parâmetro de entrada é modificado.
---
## GET /api/v1/cities
**Sem alteração na assinatura.** O campo `property_count` é adicionado à resposta.
### Response (200 OK)
```json
[
{
"id": 1,
"name": "Rio de Janeiro",
"slug": "rio-de-janeiro",
"state": "RJ",
"property_count": 47
},
{
"id": 2,
"name": "São Paulo",
"slug": "sao-paulo",
"state": "SP",
"property_count": 12
},
{
"id": 3,
"name": "Belo Horizonte",
"slug": "belo-horizonte",
"state": "MG",
"property_count": 0
}
]
```
**Regras**:
- `property_count` conta apenas imóveis com `is_active = true` associados à cidade via `properties.city_id`
- Cidades sem imóveis ativos retornam `property_count: 0` (não são omitidas da lista)
- Ordenação mantida: `state ASC, name ASC`
---
## GET /api/v1/neighborhoods
**Sem alteração na assinatura.** Parâmetro opcional `?city_id=<int>` permanece inalterado.
### Response (200 OK)
```json
[
{
"id": 10,
"name": "Copacabana",
"slug": "copacabana",
"city_id": 1,
"property_count": 23
},
{
"id": 11,
"name": "Ipanema",
"slug": "ipanema",
"city_id": 1,
"property_count": 15
},
{
"id": 12,
"name": "Santa Teresa",
"slug": "santa-teresa",
"city_id": 1,
"property_count": 0
}
]
```
**Regras**:
- `property_count` conta apenas imóveis com `is_active = true` via `properties.neighborhood_id`
- Bairros sem imóveis ativos retornam `property_count: 0`
- Ordenação mantida: `name ASC`
- Filtro `?city_id` mantém comportamento existente
---
## GET /api/v1/property-types
**Sem alteração na assinatura.** `property_count` é adicionado nos **subtypes** (leaf nodes). Os tipos pai (`parent_id = null`) retornam `property_count: 0` (sem significado — contagem relevante é nos subtipos).
### Response (200 OK)
```json
[
{
"id": 1,
"name": "Residencial",
"slug": "residencial",
"parent_id": null,
"property_count": 0,
"subtypes": [
{
"id": 2,
"name": "Apartamento",
"slug": "apartamento",
"parent_id": 1,
"property_count": 38,
"subtypes": []
},
{
"id": 3,
"name": "Casa",
"slug": "casa",
"parent_id": 1,
"property_count": 14,
"subtypes": []
},
{
"id": 4,
"name": "Cobertura",
"slug": "cobertura",
"parent_id": 1,
"property_count": 5,
"subtypes": []
}
]
},
{
"id": 5,
"name": "Comercial",
"slug": "comercial",
"parent_id": null,
"property_count": 0,
"subtypes": [
{
"id": 6,
"name": "Sala Comercial",
"slug": "sala-comercial",
"parent_id": 5,
"property_count": 7,
"subtypes": []
}
]
}
]
```
**Regras**:
- `property_count` nos subtypes conta imóveis com `is_active = true` via `properties.subtype_id`
- Tipos pai recebem `property_count: 0` (campo presente para consistência de schema, não usado pelo frontend)
- Ordenação mantida: por `PropertyType.id ASC` (categorias pai); subtypes herdados via SQLAlchemy relationship
---
## Backward Compatibility
| Aspecto | Garantia |
|---------|----------|
| Clientes existentes que ignoram campos extras | ✅ Não quebram — campo adicional em JSON é ignorado |
| Frontend antes da feature 024 | ✅ `property_count?: number` (opcional no TypeScript) — não causa erro de tipo |
| Testes existentes (`test_properties.py`, `test_homepage.py`) | ✅ Não testam payload de catálogo em detalhe; COUNT adicional não altera filtros |
| Admin panel | ✅ Não consome esses endpoints; sem impacto |

View file

@ -0,0 +1,270 @@
# Data Model: Filtro de Busca Avançada — FilterSidebar
**Feature**: `024-filtro-busca-avancada` | **Phase**: 1 | **Date**: 2026-04-20
---
## Resumo
Sem novas tabelas ou migrations. A única alteração de "modelo de dados" é a extensão dos schemas Pydantic de saída com o campo calculado `property_count: int = 0`. No frontend, os tipos TypeScript espelham a mudança com `property_count?: number` (opcional para backward-compatibility).
São adicionados dois tipos internos do TypeScript exclusivos ao `FilterSidebar.tsx`, sem impacto em outros componentes.
---
## Backend — Schemas Pydantic (catalog.py)
### Antes e depois
```python
# ANTES
class PropertyTypeOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
slug: str
parent_id: int | None
subtypes: list["PropertyTypeOut"] = []
class CityOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
slug: str
state: str
class NeighborhoodOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
slug: str
city_id: int
# DEPOIS (adição de property_count: int = 0 em cada schema)
class PropertyTypeOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
slug: str
parent_id: int | None
subtypes: list["PropertyTypeOut"] = []
property_count: int = 0 # ← NOVO
class CityOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
slug: str
state: str
property_count: int = 0 # ← NOVO
class NeighborhoodOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
slug: str
city_id: int
property_count: int = 0 # ← NOVO
```
**Regras de validação**:
- `property_count >= 0` (COUNT nunca é negativo)
- Default `0` garante que entidades sem imóveis ativos retornem um valor válido
- Campo somente-leitura (nenhum endpoint de escrita o aceita como input)
---
## Backend — Queries (routes)
### `list_cities()` — locations.py
```python
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
])
```
### `list_neighborhoods()` — locations.py
```python
q = (
db.session.query(Neighborhood, func.count(Property.id).label("cnt"))
.outerjoin(
Property,
(Property.neighborhood_id == Neighborhood.id) & (Property.is_active.is_(True))
)
.group_by(Neighborhood.id)
)
if city_id:
q = q.filter(Neighborhood.city_id == int(city_id))
rows = q.order_by(Neighborhood.name).all()
return jsonify([
{**NeighborhoodOut.model_validate(n).model_dump(), "property_count": cnt}
for n, cnt in rows
])
```
### `list_property_types()` — catalog.py
A hierarquia pai → subtypes torna a injeção de `property_count` ligeiramente diferente: o COUNT é calculado por `subtype_id` (leaf nodes), não por parent. Os tipos pai não recebem `property_count` (deixam default `0`).
```python
from sqlalchemy import func
from app.models.property import Property
# 1. Calcular counts por subtype em uma query plana
subtype_rows = (
db.session.query(
PropertyType.id,
func.count(Property.id).label("cnt")
)
.filter(PropertyType.parent_id.isnot(None)) # apenas leaf nodes
.outerjoin(
Property,
(Property.subtype_id == PropertyType.id) & (Property.is_active.is_(True))
)
.group_by(PropertyType.id)
.all()
)
count_map: dict[int, int] = {row.id: row.cnt for row in subtype_rows}
# 2. Buscar hierarquia normalmente
categories = (
PropertyType.query.filter_by(parent_id=None).order_by(PropertyType.id).all()
)
# 3. Serializar injetando property_count nos subtypes
def serialize_category(cat: PropertyType) -> dict:
data = PropertyTypeOut.model_validate(cat).model_dump(mode="json")
data["subtypes"] = [
{**sub, "property_count": count_map.get(sub["id"], 0)}
for sub in data["subtypes"]
]
return data
return jsonify([serialize_category(c) for c in categories])
```
---
## Frontend — Tipos TypeScript (catalog.ts)
```ts
// ANTES
export interface PropertyType {
id: number; name: string; slug: string
parent_id: number | null; subtypes: PropertyType[]
}
export interface City {
id: number; name: string; slug: string; state: string
}
export interface Neighborhood {
id: number; name: string; slug: string; city_id: number
}
// DEPOIS
export interface PropertyType {
id: number; name: string; slug: string
parent_id: number | null; subtypes: PropertyType[]
property_count?: number // ← NOVO (opcional — backward-compat)
}
export interface City {
id: number; name: string; slug: string; state: string
property_count?: number // ← NOVO
}
export interface Neighborhood {
id: number; name: string; slug: string; city_id: number
property_count?: number // ← NOVO
}
```
---
## Frontend — Tipos Internos (FilterSidebar.tsx)
Estes tipos são declarados localmente no arquivo `FilterSidebar.tsx` e não exportados.
### `SectionKey`
```ts
type SectionKey =
| 'imobiliaria'
| 'localizacao'
| 'tipo'
| 'preco'
| 'quartos'
| 'area'
| 'comodidades'
```
### `FilterSuggestion`
```ts
interface FilterSuggestion {
category: string // Label do grupo exibido na UI (ex.: "Tipo de imóvel")
sectionKey: SectionKey // Chave para expandir a seção correta ao selecionar
label: string // Texto exibido na sugestão
filterKey: keyof PropertyFilters
value: number | string | undefined
isAmenity?: boolean // true quando a ação é toggle em amenity_ids
amenityId?: number // preenchido quando isAmenity = true
}
```
**Mapeamento de categorias**:
| `category` (label UI) | `filterKey` | `sectionKey` | Fonte |
|-----------------------|-------------|--------------|-------|
| "Tipo de imóvel" | `subtype_id` | `tipo` | `propertyTypes[*].subtypes[*]` |
| "Cidade" | `city_id` | `localizacao` | `cities` |
| "Bairro" | `neighborhood_id` | `localizacao` | `neighborhoods` |
| "Comodidade" | n/a (`amenity_ids` toggle) | `comodidades` | `amenities` |
### `initOpenSections` — lógica de estado inicial
```ts
function initOpenSections(filters: PropertyFilters): Record<SectionKey, boolean> {
return {
imobiliaria: filters.imobiliaria_id != null,
localizacao: filters.city_id != null || filters.neighborhood_id != null,
tipo: filters.subtype_id != null,
preco: true, // sempre aberta (FR-009)
quartos: filters.bedrooms_min != null
|| filters.bathrooms_min != null
|| filters.parking_min != null,
area: filters.area_min != null || filters.area_max != null,
comodidades: (filters.amenity_ids?.length ?? 0) > 0,
}
}
```
---
## Estado do Banco de Dados
| Aspecto | Situação |
|---------|----------|
| Novas tabelas | Nenhuma |
| Migrations Alembic | Nenhuma |
| Colunas adicionadas | Nenhuma |
| Índices adicionados | Nenhum (os índices em `city_id`, `neighborhood_id`, `subtype_id` em `properties` já existem) |
| Triggers | Nenhum |
`property_count` é calculado em tempo de execução. O overhead da query é negligenciável:
- `cities`: esperado < 50 linhas × JOIN com `properties` (< 5 k linhas) < 5 ms
- `neighborhoods`: esperado < 200 linhas < 10 ms
- `property_types`: contagem por subtypes separada das categorias → < 5 ms

View file

@ -0,0 +1,334 @@
# Implementation Plan: Filtro de Busca Avançada — FilterSidebar
**Branch**: `024-filtro-busca-avancada` | **Date**: 2026-04-20 | **Spec**: [spec.md](./spec.md)
**Input**: Feature specification from `/specs/024-filtro-busca-avancada/spec.md`
---
## Summary
Enriquecer os endpoints de catálogo existentes com o campo `property_count` (COUNT dinâmico via subquery SQLAlchemy, sem migration) e reformular o `FilterSidebar.tsx` com três melhorias de UX: (1) campo de busca cross-categoria com debounce 200 ms e sugestões agrupadas inline, (2) estado inicial controlado com apenas a seção "Preço" aberta e auto-expansão das seções que contêm filtros ativos da URL, e (3) truncamento das listas (top-5 visíveis + "Ver mais") com ordenação por popularidade e badge "Popular" nos 3 mais populares. Sem novas tabelas, sem novos endpoints, sem novas páginas, sem alteração de rotas.
---
## Technical Context
**Language/Version**: Python 3.12 (backend) · TypeScript 5.5 (frontend)
**Primary Dependencies**: Flask 3.x, SQLAlchemy 2.x, Pydantic v2 (backend) · React 18, Tailwind CSS 3.4, Axios (frontend)
**Storage**: PostgreSQL 16 — sem novas tabelas ou migrations (`property_count` é calculado via `func.count` + `outerjoin` no ORM, não persistido)
**Testing**: pytest (backend — testes de integração nos endpoints enriquecidos)
**Target Platform**: Browser SPA (desktop); Linux server via Docker
**Project Type**: web-service (Flask REST API) + SPA (React)
**Performance Goals**: busca cross-categoria < 50 ms (processamento local, NFR-001); endpoints de catálogo < 300 ms p95 (COUNT em tabelas pequenas, < 5 k registros)
**Constraints**: sem nova dependência npm ou Python; sem rotas novas; filtros mobile fora de escopo; estado de expansão das seções não persistido em `localStorage` (NFR per spec)
**Scale/Scope**: 3 schemas Pydantic editados, 2 rotas Flask editadas, 1 componente React reformulado (~600 linhas → ~800 linhas), 2 arquivos de tipos TypeScript editados
---
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
| Princípio | Status | Observação |
|-----------|--------|------------|
| **I. Design-First** | ✅ PASS | Campo de busca, badge "Popular" e botão "Ver mais" usam exclusivamente tokens do `DESIGN.md`: `textTertiary`, `textSecondary`, `borderSubtle`, `borderStandard`, `surface`, `brand`; animação `duration-200 ease-out` do CSS grid trick existente é reutilizada |
| **II. Separation of Concerns** | ✅ PASS | Backend calcula e expõe `property_count` em JSON; toda lógica de busca, ordenação, truncamento e expansão de seções ocorre no cliente; Flask não renderiza HTML |
| **III. Spec-Driven** | ✅ PASS | `spec.md` aprovado com user stories P1/P2 e acceptance scenarios; este plano é derivado do spec |
| **IV. Data Integrity** | ✅ PASS | `property_count` é campo somente-leitura calculado por COUNT (não alterável via API); Pydantic v2 declara `property_count: int = 0` com default; NFR-005: COUNT filtra `is_active == True` |
| **V. Security** | ✅ PASS | NFR-005: contagem exclui imóveis inativos; nenhum dado privado exposto; campo é adicional somente-leitura nos endpoints públicos já existentes |
| **VI. Simplicity First** | ✅ PASS | Debounce manual via `useEffect`/`setTimeout` (sem lodash); normalização nativa `String.normalize('NFD')`; sem nova biblioteca; estado de seções em `Record<string, boolean>` simples; COUNT via subquery SQLAlchemy sem hybrid property ou stored procedure |
**Veredicto**: Sem violações. Pode prosseguir para Phase 0.
**Re-check pós-design (Phase 1)**: ✅ Confirmado — nenhuma abstração prematura introduzida; `property_count` via subquery é o mecanismo mais simples que atende NFR-005 sem migration.
---
## Project Structure
### Documentation (this feature)
```text
specs/024-filtro-busca-avancada/
├── plan.md ← Este arquivo
├── research.md ← Phase 0 output
├── data-model.md ← Phase 1 output
├── quickstart.md ← Phase 1 output
├── contracts/
│ └── api-catalog-enhancements.md ← Phase 1 output
└── tasks.md ← Phase 2 output (/speckit.tasks — NÃO gerado aqui)
```
### Source Code (repository root)
```text
backend/
└── app/
├── schemas/
│ └── catalog.py ← EDITADO — property_count: int = 0 em PropertyTypeOut, CityOut, NeighborhoodOut
└── routes/
├── catalog.py ← EDITADO — subquery COUNT em list_property_types()
└── locations.py ← EDITADO — subquery COUNT em list_cities() e list_neighborhoods()
frontend/
└── src/
├── types/
│ └── catalog.ts ← EDITADO — property_count?: number em PropertyType, City, Neighborhood
└── components/
└── FilterSidebar.tsx ← EDITADO (principal — ~200 linhas adicionadas)
```
**Structure Decision**: Projeto web full-stack (Option 2). Sem novos arquivos — apenas edições cirúrgicas em arquivos existentes. Toda a lógica nova de sidebar fica contida em `FilterSidebar.tsx` (sub-componentes locais); nenhum hook ou serviço separado é criado porque a lógica não é compartilhada com outros componentes (YAGNI).
---
## Complexity Tracking
*Nenhuma violação de Constitution detectada. Seção não aplicável.*
---
## Architecture & Data Flow
### Fluxo de dados: backend → frontend
```
PostgreSQL
└─ properties (is_active=TRUE) ──COUNT──┐
Flask routes subquery via SQLAlchemy func.count + outerjoin
├─ GET /api/v1/cities → CityOut[] (+ property_count)
├─ GET /api/v1/neighborhoods → NeighborhoodOut[] (+ property_count)
└─ GET /api/v1/property-types → PropertyTypeOut[] (subtypes + property_count)
catalog.ts getCities(), getNeighborhoods(), getPropertyTypes()
FilterSidebar.tsx props cities[], neighborhoods[], propertyTypes[]
├─ ordena por property_count DESC (localmente)
├─ mostra top-5, botão "Ver mais (N)" se > 5
├─ badge "Popular" nos 3 primeiros (index < 3)
└─ item selecionado sempre visível (promoted ao topo se oculto)
```
### Fluxo de dados: busca cross-categoria
```
[usuário digita no campo "Buscar filtro…"]
filterSearch (state) ── debounce 200ms ──→ searchQuery (state)
searchQuery !== '' ?
├─ YES → computeSuggestions(searchQuery, propertyTypes, cities, neighborhoods, amenities)
│ normaliza (NFD, lowercase, sem acento)
│ → FilterSuggestion[] agrupados por category
│ → <SuggestionList> inline sob o campo
└─ NO → renderização normal das seções accordion
[clique em sugestão]
├─ set({ [filterKey]: value, page: 1 }) (aplica filtro)
├─ expandSection(sectionKey) (abre seção relevante)
└─ setFilterSearch('') (limpa campo)
```
### Fluxo de dados: estado de expansão das seções
```
URL params (filters.city_id, filters.subtype_id, …) ─→ initOpenSections(filters)
openSections: Record<SectionKey, boolean>
{
imobiliaria: false,
localizacao: filters.city_id != null || filters.neighborhood_id != null,
tipo: filters.subtype_id != null,
preco: true, ← sempre aberta por padrão
quartos: filters.bedrooms_min != null || …,
area: filters.area_min != null || filters.area_max != null,
comodidades: (filters.amenity_ids?.length ?? 0) > 0,
}
Section recebe `open={openSections[key]}` + `onToggle={() => toggleSection(key)}`
(Section passa para controlled mode, mantendo uncontrolled como fallback)
```
---
## Components Affected
### Backend
| Arquivo | Tipo | Mudança |
|---------|------|---------|
| `backend/app/schemas/catalog.py` | EDIT | Adicionar `property_count: int = 0` em `PropertyTypeOut`, `CityOut`, `NeighborhoodOut` |
| `backend/app/routes/catalog.py` | EDIT | `list_property_types()`: calcular `property_count` por subtype via subquery COUNT; injetar no dict antes de serializar |
| `backend/app/routes/locations.py` | EDIT | `list_cities()`: query com `outerjoin(Property)` + `func.count` + `group_by`; `list_neighborhoods()`: idem |
### Frontend
| Arquivo | Tipo | Mudança |
|---------|------|---------|
| `frontend/src/types/catalog.ts` | EDIT | Adicionar `property_count?: number` em `PropertyType`, `City`, `Neighborhood` |
| `frontend/src/components/FilterSidebar.tsx` | EDIT | Reformulação principal — ver detalhes abaixo |
### FilterSidebar.tsx — mudanças internas
| Sub-componente / Lógica | Status | Descrição |
|-------------------------|--------|-----------|
| `Section` | EDIT | Suporte a `open?: boolean` + `onToggle?: () => void` (controlled mode); mantém `useState(defaultOpen)` como fallback quando `open` não é passado |
| `SidebarSearchInput` | NEW (local) | `<input>` com ícone de lupa, placeholder "Buscar filtro…", desabilitado quando `catalogLoading` |
| `SuggestionList` | NEW (local) | Lista inline de `FilterSuggestion[]` agrupados, com navegação por teclado (↑↓ Enter Escape) |
| `PopularBadge` | NEW (local) | `<span>Popular</span>` com tokens `brand/20` bg e `brand` text, `text-[10px]` |
| `TruncatedFilterList` | NEW (local) | Wrapper que exibe top-5 + botão "Ver mais (N)" / "Ver menos"; garante visibilidade de item selecionado |
| `computeSuggestions()` | NEW (função local) | Normaliza query, filtra todos os itens de catálogo, retorna `FilterSuggestion[]` |
| `initOpenSections()` | NEW (função local) | Deriva `Record<SectionKey, boolean>` a partir de `PropertyFilters` — seção Preço sempre `true` |
| `openSections` state | NEW | `useState<Record<SectionKey, boolean>>` inicializado via `initOpenSections(filters)` |
| `filterSearch` / `searchQuery` state | NEW | `useState<string>` para input + estado debounced |
| Seção "Imobiliária" | EDIT | `defaultOpen={false}` → controlled via `openSections` |
| Seção "Localização" | EDIT | `defaultOpen` condicional → controlled via `openSections` |
| Seção "Tipo de imóvel" | EDIT | idem + `TruncatedFilterList` nos subtypes de cada categoria |
| Seção "Preço" | EDIT | `defaultOpen={true}` já existe → agora controlled via `openSections.preco = true` |
| Seção "Quartos e vagas" | EDIT | controlled via `openSections` |
| Seção "Área" | EDIT | controlled via `openSections` |
| Seção "Comodidades" | EDIT | controlled via `openSections`; `TruncatedFilterList` por grupo |
---
## Technical Decisions
### TD-001: `property_count` via subquery dinâmica (sem migration, sem hybrid property)
**Decisão**: Calcular `property_count` nos route handlers via `db.session.query(City, func.count(Property.id)).outerjoin(…).group_by(City.id)`. Adicionar `property_count: int = 0` aos schemas Pydantic com default `0`.
**Rationale**: Evita migration desnecessária (Constitution IV). `property_count` é dado de leitura; persistir seria denormalização sem benefício real dado o volume (< 5 k imóveis). Subquery em tabelas pequenas é negligenciável em performance.
**Alternativas descartadas**:
- SQLAlchemy `column_property` com correlated subquery: mais limpo no ORM mas adiciona complexidade ao modelo sem ganho real (usado uma vez).
- Coluna persistida com trigger: over-engineering (Constitution VI); requer migration + lógica de atualização.
**Impacto na serialização**: Os routes handlers passam a construir dicts manualmente para City/Neighborhood. Para PropertyType (hierárquico), o `property_count` é injetado nos subtypes após serialização com `model_dump() | {'property_count': count_map.get(sub.id, 0)}`.
### TD-002: Section em controlled mode com fallback uncontrolled
**Decisão**: Estender `Section` para aceitar props opcionais `open?: boolean` e `onToggle?: () => void`. Quando `open` é definido, o componente é controlled; caso contrário mantém `useState(defaultOpen)` atual.
**Rationale**: Backward-compatible — nenhum caller externo é quebrado. O `FilterSidebar` passa a controlar o estado de todas as seções via `openSections`. A busca cross-categoria pode então expandir a seção relevante via `expandSection(key)` sem lógica especial.
**Impacto**: Apenas `Section` dentro de `FilterSidebar.tsx` é afetado; `Section` não é exportado.
### TD-003: Debounce manual (sem lodash/use-debounce)
**Decisão**: Implementar debounce 200 ms via `useEffect(() => { const t = setTimeout(..., 200); return () => clearTimeout(t); }, [filterSearch])`.
**Rationale**: NFR-001 exige processamento local. Adicionar `lodash` ou `use-debounce` só para isso viola Constitution VI (YAGNI). O padrão `useEffect` + `setTimeout` é idiomático em React e sem dependência extra.
### TD-004: Normalização de texto sem biblioteca
**Decisão**: `text.normalize('NFD').replace(/\p{Mn}/gu, '').toLowerCase()`.
**Rationale**: Cobre 100% dos casos do spec (acentos, cedilha — FR-005) sem adicionar dependência. Suportado em todos os browsers modernos e Node 18+.
### TD-005: Badge "Popular" — top-3 por seção após ordenação
**Decisão**: Após ordenar `items` por `property_count DESC` no frontend, aplicar badge nos itens com `index < 3` (os 3 primeiros). A lógica reside em `TruncatedFilterList`.
**Rationale**: Spec FR-014. Simples e correto. O backend já envia ordenado; o frontend apenas exibe badge nos primeiros três.
### TD-006: Item selecionado sempre visível no truncamento
**Decisão**: Antes de exibir top-5, verificar se o item ativo (ex.: `filters.subtype_id`) está entre os 5 primeiros. Se não estiver, promovê-lo para o início da lista (sem alterar a ordem geral) garantindo que apareça sem "Ver mais". Spec FR-015.
**Rationale**: Evita confusão do usuário que selecionou um filtro mas não o vê no sidebar. Promoção temporária é local e não altera dados.
---
## Diagram of Changes
```
┌─────────────────────────────────────────────────────────────────┐
│ BACKEND │
│ │
│ schemas/catalog.py │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ PropertyTypeOut + property_count: int = 0 │ │
│ │ CityOut + property_count: int = 0 │ │
│ │ NeighborhoodOut + property_count: int = 0 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ routes/locations.py │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ list_cities() │ │
│ │ query(City, func.count(Property.id)) │ │
│ │ .outerjoin(Property.city_id == City.id │ │
│ │ & Property.is_active == True) │ │
│ │ .group_by(City.id) │ │
│ │ │ │
│ │ list_neighborhoods() [mesma lógica] │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ routes/catalog.py │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ list_property_types() │ │
│ │ count_map: dict[int, int] ← subquery por subtype_id │ │
│ │ injeta property_count em cada subtype dict │ │
│ └─────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
│ JSON (enriquecido)
┌─────────────────────────────────────────────────────────────────┐
│ FRONTEND │
│ │
│ types/catalog.ts │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ PropertyType + property_count?: number │ │
│ │ City + property_count?: number │ │
│ │ Neighborhood + property_count?: number │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ FilterSidebar.tsx │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ NOVOS estados: │ │
│ │ filterSearch: string │ │
│ │ searchQuery: string (debounced) │ │
│ │ openSections: Record<SectionKey, boolean> │ │
│ │ │ │
│ │ NOVOS componentes locais: │ │
│ │ SidebarSearchInput ← campo "Buscar filtro…" │ │
│ │ SuggestionList ← sugestões agrupadas inline │ │
│ │ TruncatedFilterList ← top-5 + "Ver mais" + badge │ │
│ │ PopularBadge ← badge "Popular" (brand token) │ │
│ │ │ │
│ │ NOVAS funções locais: │ │
│ │ computeSuggestions() ← normaliza + filtra catálogo │ │
│ │ initOpenSections() ← deriva estado de URL filters │ │
│ │ │ │
│ │ EDITADOS: │ │
│ │ Section ← controlled mode opcional │ │
│ │ Todas as seções ← usam openSections │ │
│ │ Seções com listas ← usam TruncatedFilterList │ │
│ └─────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
```
---
## Edge Cases & Mitigations
| Edge case (spec) | Mitigação implementada |
|------------------|----------------------|
| Mesmo nome em cidade e bairro (ex.: "Santos") | `FilterSuggestion.filterKey` distingue `city_id` vs `neighborhood_id`; ambos aparecem em grupos diferentes na `SuggestionList` |
| Busca com acentos/cedilha/hífen | `normalize('NFD') + replace(/\p{Mn}/gu, '')` no query e nos labels antes da comparação |
| Filtro selecionado removido do catálogo | O item ativo não aparece na lista mas o badge de contagem no `Section` continua mostrando `1`; o filtro permanece aplicado até o usuário limpar — comportamento existente, não alterado |
| Item selecionado entre os ocultos no "Ver mais" | `TruncatedFilterList` promove item ativo ao topo quando `showAll = false`; sempre visível sem precisar expandir |
| Dados de `property_count` ainda carregando | `catalogLoading = true``SidebarSearchInput` desabilitado (FR-007); listas sem badge/ordenação especial até dados chegarem |
| Seção de preço com `listing_type` mudando | `openSections` é inicializado uma vez (no mount); mudança de `listing_type` não recolapsa seção Preço — comportamento correto per spec |
| Campo de busca quando `catalogLoading` | `disabled` + `opacity-50 cursor-not-allowed` via Tailwind; nenhuma sugestão computada |
---
## Out of Scope (confirmed from spec)
- Filtros mobile (sheet/modal) — feature separada
- Histórico ou salvamento de filtros
- Busca por imóveis individuais (código, endereço) — coberto pela feature 023
- Badge "Popular" em comodidades (amenities não têm `property_count` — a contagem seria N:N e menos relevante; a spec cobre tipos, cidades e bairros)
- Paginação de sugestões
- Internacionalização de labels

View file

@ -0,0 +1,181 @@
# Quickstart: Filtro de Busca Avançada — FilterSidebar
**Feature**: `024-filtro-busca-avancada` | **Date**: 2026-04-20
Guia de verificação rápida após implementação. Execute em ordem.
---
## Pré-requisitos
```powershell
# Containers rodando
docker compose up -d
# Backend saudável
curl http://localhost:5000/api/v1/cities
```
---
## 1. Verificar `property_count` no backend
### Cidades
```bash
curl -s http://localhost:5000/api/v1/cities | python -m json.tool | grep property_count
```
**Esperado**: cada objeto de cidade tem `"property_count": <número inteiro ≥ 0>`.
### Bairros
```bash
curl -s "http://localhost:5000/api/v1/neighborhoods?city_id=1" | python -m json.tool | grep property_count
```
**Esperado**: campo presente, valores numéricos, bairros sem imóvel retornam `0`.
### Tipos de imóvel
```bash
curl -s http://localhost:5000/api/v1/property-types | python -m json.tool
```
**Esperado**:
- `subtypes[*].property_count` presente e ≥ 0
- Tipos pai (`parent_id: null`) têm `property_count: 0`
### NFR-005 — apenas imóveis ativos
```bash
# Desativar um imóvel e verificar que property_count decresce
docker exec saas_imobiliaria-backend-1 uv run python -c "
from app import create_app; from app.extensions import db; from app.models.property import Property
app = create_app()
with app.app_context():
p = Property.query.filter_by(is_active=True).first()
if p:
print('Imóvel:', p.city_id, p.neighborhood_id)
"
```
---
## 2. Verificar estado inicial das seções (User Story 2)
1. Acessar `http://localhost:5173/imoveis` **sem parâmetros de URL**
2. Verificar que **apenas** a seção "Preço" está expandida ao carregar
3. Verificar que "Localização", "Tipo de imóvel", "Quartos e vagas", "Área", "Comodidades" estão **colapsadas**
```
✅ Apenas "Preço" expandida → FR-009 PASS
```
4. Acessar `http://localhost:5173/imoveis?city_id=1&bedrooms_min=2`
5. Verificar que as seções "Localização" e "Quartos e vagas" estão **expandidas** além de "Preço"
```
✅ Seções com filtros ativos expandidas → FR-010 PASS
```
---
## 3. Verificar truncamento e popularidade (User Story 3)
1. Abrir a seção "Localização" → subseção Bairros (com cidade selecionada que tenha > 5 bairros)
2. Verificar que **apenas 5 bairros** são exibidos inicialmente
3. Verificar que os **3 primeiros** têm badge "Popular" visível
4. Verificar que os 5 exibidos são os **mais populares** (maiores `property_count`)
5. Clicar em "Ver mais (N)" → todos os bairros são exibidos
6. Clicar em "Ver menos" → volta aos 5 primeiros
```
✅ Truncamento top-5 → FR-012 PASS
✅ Ordenação por popularidade → FR-013 PASS
✅ Badge "Popular" → FR-014 PASS
✅ "Ver mais" toggle → FR-017 PASS
```
7. Selecionar um bairro que **não está entre os 5 primeiros**
8. Colapsar e reabrir a seção
9. Verificar que o bairro selecionado **aparece visível** mesmo sem clicar em "Ver mais"
```
✅ Item selecionado sempre visível → FR-015 PASS
```
---
## 4. Verificar busca cross-categoria (User Story 1)
1. Verificar que o campo "Buscar filtro…" aparece **acima** de todas as seções accordion
2. Digitar `"apar"` e aguardar 200 ms
3. Verificar sugestão inline com grupo "Tipo de imóvel" → "Apartamento"
4. Clicar em "Apartamento" → filtro `subtype_id` aplicado, campo limpo, seção "Tipo de imóvel" expandida
```
✅ FR-001, FR-002, FR-003, FR-004, FR-006 PASS
```
5. Digitar `"copa"` → verificar "Copacabana" sob grupo "Bairro"
6. Digitar `"apto xyz 999"` → verificar mensagem "Nenhum filtro encontrado para "apto xyz 999""
```
✅ FR-005 (normalização), FR-005 (case-insensitive) PASS
```
7. Com sugestões visíveis:
- Pressionar `↓` → primeiro item highlighted
- Pressionar `↓` novamente → segundo item
- Pressionar `Enter` → filtro aplicado
- Abrir novamente, pressionar `Escape` → sugestões fechadas
```
✅ FR-008 (navegação por teclado) PASS
```
8. Testar com `catalogLoading = true` (simular estado de carregamento):
- Campo deve aparecer desabilitado (opacidade reduzida, cursor not-allowed)
```
✅ FR-007 PASS
```
---
## 5. Verificar design tokens (NFR-003)
Inspecionar no DevTools:
| Elemento | Token esperado | Classe Tailwind |
|----------|---------------|-----------------|
| Campo de busca (borda) | `borderSubtle` | `border-borderSubtle` |
| Campo de busca (texto placeholder) | `textTertiary` | `placeholder-textTertiary` |
| Badge "Popular" (fundo) | `brand/20` | `bg-brand/20` |
| Badge "Popular" (texto) | `brand` | `text-brand` |
| Botão "Ver mais" | `textTertiary` | `text-textTertiary` |
| Cabeçalhos de grupos de sugestão | `textTertiary` uppercase | `text-textTertiary uppercase` |
| Animação sugestões | `duration-200 ease-out` | CSS grid trick |
---
## 6. Verificar que filtros existentes não quebraram (NFR-004 / SC-005)
```bash
cd backend && uv run pytest tests/ -v
```
**Esperado**: todos os testes passando.
Testar manualmente na UI:
- Filtro por preço (min/max + presets)
- Filtro por quartos/banheiros/vagas (chips)
- Filtro por área
- Filtro por comodidades (checkboxes)
- Botão "Limpar (N)"
- Toggle Venda / Aluguel
```
✅ Nenhum filtro existente quebrado → NFR-004, SC-005 PASS
```

View 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 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.

View file

@ -0,0 +1,165 @@
# Feature Specification: Filtro de Busca Avançada — FilterSidebar
**Feature Branch**: `024-filtro-busca-avancada`
**Created**: 2026-04-20
**Status**: Draft
**Fonte**: Solicitação de melhoria UX no sidebar de filtros da página `/imoveis`
---
## Contexto
O `FilterSidebar` da página `/imoveis` já existe e é funcional, mas apresenta três lacunas de usabilidade que impactam a descoberta de imóveis:
1. **Ausência de busca cross-categoria dentro do sidebar**: o usuário precisa abrir seção por seção para encontrar um tipo de imóvel ou bairro específico.
2. **Todas as seções abertas por padrão**: ao carregar a página, o sidebar está completamente expandido, gerando sobrecarga visual e exigindo scroll antes mesmo de ver os resultados.
3. **Listas longas sem hierarquia de popularidade**: bairros e tipos são listados em ordem arbitrária; itens raramente usados ocupam o mesmo espaço visual que os mais procurados, dificultando a seleção rápida.
---
## User Scenarios & Testing
### User Story 1 — Campo de Busca Cross-Categoria no Sidebar (Priority: P1)
Um visitante que sabe exatamente o que procura (ex.: "Copacabana", "Cobertura") digita o termo no campo de busca do sidebar e vê instantaneamente em qual categoria aquela opção se enquadra, podendo selecioná-la com um clique sem precisar abrir seções manualmente.
**Why this priority**: A busca cross-categoria resolve o maior obstáculo de navegação do sidebar: o usuário com intenção definida não sabe em qual seção procurar. É o ganho de UX mais alto com menor complexidade de implementação — nenhum dado novo de backend é necessário, pois os dados já chegam via `catalog.ts`.
**Independent Test**: Digitar "Copa" no campo de busca do sidebar e verificar que aparece uma lista de sugestões agrupadas mostrando "Copacabana" sob o grupo "Bairro"; clicar em "Copacabana" e confirmar que o filtro de bairro é aplicado.
**Acceptance Scenarios**:
1. **Given** a página `/imoveis` com o sidebar visível, **When** o usuário vê o topo do sidebar, **Then** um campo de busca com placeholder "Buscar filtro…" está disponível acima das seções accordion.
2. **Given** o campo de busca do sidebar com o valor "apar", **When** o usuário para de digitar por 200ms, **Then** uma lista de sugestões aparece inline (não em dropdown popup) mostrando entradas agrupadas, por exemplo: grupo "Tipo de imóvel" → "Apartamento".
3. **Given** sugestões de busca visíveis com múltiplas categorias correspondentes, **When** o usuário vê a lista, **Then** cada grupo tem um cabeçalho de categoria (ex.: "Tipo de imóvel", "Bairro", "Cidade") e os itens correspondentes abaixo.
4. **Given** uma sugestão visível, **When** o usuário clica nela, **Then** o filtro correspondente é aplicado (equivalente a selecionar o item na seção accordion), o campo de busca é limpo e a seção relevante é expandida para mostrar o item selecionado.
5. **Given** o campo de busca preenchido sem nenhuma correspondência, **When** a busca é executada, **Then** uma mensagem "Nenhum filtro encontrado para "[termo]"" é exibida no lugar das sugestões.
6. **Given** o campo de busca preenchido, **When** o usuário pressiona Escape ou limpa o campo, **Then** as sugestões são ocultadas e o estado das seções retorna ao normal.
7. **Given** a navegação por teclado com sugestões visíveis, **When** o usuário pressiona as teclas de seta (↓↑) e Enter, **Then** ele pode selecionar uma sugestão sem usar o mouse.
---
### User Story 2 — Seção de Preço Aberta por Padrão, Demais Colapsadas (Priority: P1)
Um visitante que acaba de chegar na página `/imoveis` encontra o sidebar com uma experiência limpa: apenas a seção de preço está expandida, tornando o filtro mais importante imediatamente visível, enquanto as demais seções ficam colapsadas e acessíveis sob demanda.
**Why this priority**: Juntamente com a busca cross-categoria, esta mudança tem impacto imediato na percepção de organização do sidebar sem exigir dados adicionais do backend. A seção de preço é o filtro de maior influência na decisão do usuário, justificando seu destaque inicial.
**Independent Test**: Carregar `/imoveis` pela primeira vez (ou sem parâmetros de URL) e verificar que somente a seção "Preço" está expandida; todas as demais seções (Tipo, Quartos, Bairros, etc.) estão colapsadas.
**Acceptance Scenarios**:
1. **Given** a página `/imoveis` carregada sem filtros ativos, **When** o sidebar renderiza, **Then** apenas a seção "Preço" está expandida (`defaultOpen = true`); todas as demais seções têm `defaultOpen = false`.
2. **Given** filtros ativos presentes na URL (ex.: `?city=1&bedrooms=2`), **When** o sidebar renderiza, **Then** as seções que contêm filtros ativos ficam automaticamente expandidas, além da seção de Preço.
3. **Given** o usuário colapsou manualmente a seção de Preço e navega para outra página e retorna, **When** o sidebar renderiza, **Then** o estado de colapso/expansão das seções volta ao padrão (Preço aberto, demais fechados), pois esse estado não é persistido.
---
### User Story 3 — Listas com "Ver mais" e Ordenação por Popularidade (Priority: P2)
Um visitante que navega pelos filtros de bairro ou tipo de imóvel vê imediatamente os N itens mais relevantes (com mais imóveis disponíveis), pode expandir para ver todos com "Ver mais", e identifica visualmente os itens mais populares por meio de badges ou destaque.
**Why this priority**: Listas longas sem hierarquia sobrecarregam a interface e enterram as opções mais úteis. Exibir os mais populares primeiro e truncar com "Ver mais" é o padrão de portais imobiliários líderes. Requer dados de contagem do backend, tornando-o de implementação mais complexa que as stories P1.
**Independent Test**: Abrir a seção "Bairros" no sidebar e verificar que apenas os 5 bairros com mais imóveis são exibidos inicialmente, com badge "Popular" no primeiro; clicar em "Ver mais" e confirmar que todos os bairros são exibidos.
**Acceptance Scenarios**:
1. **Given** uma seção de filtro com mais de 5 itens (ex.: Bairros), **When** o accordion é expandido, **Then** apenas os 5 primeiros itens são exibidos, ordenados do mais popular (mais imóveis associados) para o menos popular.
2. **Given** uma seção com mais de 5 itens exibindo os primeiros 5, **When** o usuário clica no botão "Ver mais (N)", **Then** todos os itens restantes são exibidos inline (sem modal ou navegação), e o botão muda para "Ver menos".
3. **Given** uma seção expandida com todos os itens visíveis, **When** o usuário clica em "Ver menos", **Then** a lista retorna a exibir apenas os 5 primeiros e o scroll retorna ao início da seção.
4. **Given** os itens de uma seção ordenados por popularidade, **When** o usuário vê a lista, **Then** os 3 itens com mais imóveis associados exibem um badge "Popular" ao lado do nome.
5. **Given** um filtro já selecionado que não está entre os 5 primeiros da lista, **When** o usuário reabre a seção com o filtro ativo, **Then** o item selecionado é exibido mesmo que esteja além dos 5 iniciais (o truncamento não oculta itens selecionados).
6. **Given** uma seção com 5 itens ou menos, **When** o accordion é expandido, **Then** o botão "Ver mais" não é exibido.
---
### Edge Cases
- O que acontece quando a busca cross-categoria retorna o mesmo item em múltiplas categorias (ex.: cidade e bairro com o mesmo nome)?
- Como o sistema lida com termos de busca contendo caracteres especiais (acentos, cedilha, hifens)?
- O que acontece quando um filtro selecionado não aparece mais na lista porque o backend não o retornou (ex.: cidade removida do catálogo)?
- Como o "Ver mais" se comporta quando um item selecionado está entre os ocultos — ele precisa estar visível mesmo sem clicar em "Ver mais"?
- O que acontece se os dados de popularidade (contagem de imóveis por bairro/tipo) ainda estão carregando quando o accordion é expandido?
- Como a seção de preço se comporta quando o parâmetro `listing_type` muda entre "venda" e "aluguel" (os presets já mudam — o estado de expansão da seção é preservado)?
- Qual é o comportamento do campo de busca do sidebar quando o catálogo ainda está carregando (`catalogLoading = true`)?
---
## Requirements
### Functional Requirements
#### Busca Cross-Categoria no Sidebar
- **FR-001**: O `FilterSidebar` DEVE exibir um campo de busca textual no topo, acima de todas as seções accordion, com placeholder "Buscar filtro…".
- **FR-002**: O campo de busca DEVE pesquisar simultaneamente em todas as categorias de filtro disponíveis: tipos de imóvel, cidades, bairros e comodidades.
- **FR-003**: A busca DEVE aplicar debounce de 200ms antes de exibir resultados, para não travar a interface durante digitação rápida.
- **FR-004**: Os resultados DEVEM ser apresentados agrupados por categoria, com o cabeçalho de cada grupo claramente identificado (ex.: "Tipo de imóvel", "Bairro", "Cidade", "Comodidade").
- **FR-005**: A busca DEVE ser case-insensitive e ignorar acentuação para maximizar correspondências (ex.: "copacabana" deve encontrar "Copacabana", "apto" deve encontrar "Apartamento").
- **FR-006**: Ao clicar em uma sugestão, o filtro correspondente DEVE ser aplicado imediatamente, o campo de busca DEVE ser limpo e a seção do accordion correspondente DEVE ser expandida.
- **FR-007**: Quando `catalogLoading = true`, o campo de busca DEVE estar desabilitado com estado visual de loading (cursor não permitido, opacidade reduzida).
- **FR-008**: A navegação por teclado (↑↓ para mover entre sugestões, Enter para selecionar, Escape para fechar) DEVE ser suportada para acessibilidade.
#### Estado Inicial das Seções
- **FR-009**: Ao carregar o `FilterSidebar` sem filtros pré-ativos na URL, apenas a seção "Preço" DEVE ter `defaultOpen = true`; todas as demais seções DEVEM ter `defaultOpen = false`.
- **FR-010**: Quando filtros pré-ativos existem na URL, as seções que contêm esses filtros ativos DEVEM ser inicialmente expandidas além da seção de Preço.
- **FR-011**: O estado de expansão das seções NÃO DEVE ser persistido entre sessões — cada carregamento de página retorna ao estado padrão.
#### Listas com Truncamento e Popularidade
- **FR-012**: Seções com mais de 5 itens DEVEM exibir apenas os 5 primeiros inicialmente, com um botão "Ver mais (N)" indicando quantos itens adicionais existem.
- **FR-013**: Os itens de cada seção DEVEM ser ordenados por popularidade, definida como a contagem de imóveis ativos associados àquele item (ex.: número de imóveis em cada bairro, número de imóveis de cada tipo).
- **FR-014**: Os 3 itens mais populares de cada seção DEVEM exibir um badge "Popular" ao lado do label.
- **FR-015**: Itens com filtro ativo (selecionados) DEVEM sempre ser visíveis, independentemente de estarem entre os 5 primeiros ou não.
- **FR-016**: O backend DEVE fornecer dados de contagem de imóveis por categoria (tipo de imóvel, cidade, bairro) para permitir ordenação por popularidade na camada de catálogo.
- **FR-017**: O botão "Ver mais" DEVE ter comportamento toggle: ao ser clicado novamente, exibe "Ver menos" e retorna a lista ao truncamento inicial.
### Requisitos Não-Funcionais
- **NFR-001**: A busca cross-categoria DEVE executar inteiramente no cliente (sem chamadas adicionais à API), utilizando os dados de catálogo já carregados.
- **NFR-002**: A animação de expansão/colapso das sugestões de busca DEVE seguir a mesma curva de animação das seções accordion existentes (CSS grid trick, `duration-200 ease-out`).
- **NFR-003**: O visual de todos os elementos novos (campo de busca, badges, botão "Ver mais") DEVE seguir os design tokens do projeto: `textTertiary`, `textSecondary`, `borderSubtle`, `borderStandard`, `surface`, `brand`.
- **NFR-004**: Nenhuma alteração visual ou de comportamento DEVE impactar o funcionamento dos filtros existentes (os filtros aplicados via accordion continuam funcionando normalmente).
- **NFR-005**: A contagem de imóveis por categoria retornada pelo backend DEVE ser calculada apenas sobre imóveis ativos e disponíveis, sem expor dados de imóveis inativos.
### Key Entities
- **CatalogItem com Contagem**: Extensão das entidades de catálogo existentes (tipo de imóvel, cidade, bairro) com o campo `property_count` indicando quantos imóveis ativos estão associados.
- **SugestãoDeFiltro**: Resultado da busca cross-categoria, composto por `category` (grupo), `label` (texto exibido), `value` (identificador) e `filterKey` (chave do filtro a ser aplicado em `PropertyFilters`).
- **EstadoDeExpansãoDaSeção**: Mapeamento interno de `sectionKey → boolean` controlando quais seções do accordion estão abertas; inicializado a partir dos filtros ativos da URL.
---
## Success Criteria
### Measurable Outcomes
- **SC-001**: Usuários com intenção definida (sabem o bairro ou tipo que buscam) conseguem aplicar o filtro desejado em menos de 3 interações a partir do campo de busca do sidebar.
- **SC-002**: A seção de preço é a primeira coisa interativa que o usuário vê ao abrir o sidebar em 100% dos carregamentos sem filtros pré-ativos.
- **SC-003**: Em seções com mais de 5 itens, os 5 exibidos inicialmente cobrem pelo menos 70% do volume de imóveis disponíveis (validação pelo ordenamento por popularidade).
- **SC-004**: O tempo de resposta do campo de busca cross-categoria (desde o fim do debounce até a exibição das sugestões) é imperceptível para o usuário (abaixo de 50ms, pois é processamento local).
- **SC-005**: Nenhum filtro previamente funcional quebra após a implementação — todos os cenários de aceitação da spec `023-ux-melhorias-imoveis` continuam passando.
---
## Assumptions
- Os dados de catálogo (tipos, cidades, bairros, comodidades) já estão disponíveis via `catalog.ts` no momento em que o `FilterSidebar` renderiza; não há nova chamada de API necessária para a busca cross-categoria.
- A ordenação por popularidade requer uma única adição ao endpoint de catálogo existente (`property_count` por item), não um endpoint separado.
- O número de itens por seção raramente ultrapassa 2030 em produção para este SaaS (portfólio de uma imobiliária), tornando o processamento de busca local viável sem paginação.
- O estado de expansão das seções não será persistido em `localStorage` nesta versão; a persistência pode ser adicionada em iteração futura se houver demanda.
- O badge "Popular" é puramente visual e não afeta a lógica de filtragem.
- A busca cross-categoria opera sobre dados do catálogo (tipos, cidades, bairros, comodidades) e não sobre dados de imóveis individuais (endereços, códigos) — a busca por endereço/código já está coberta pelo campo de busca global da feature `023`.
---
## Fora do Escopo
- Salvar buscas favoritas ou histórico de filtros por usuário.
- Busca cross-categoria que inclua imóveis individuais (por código, endereço ou título) — isso é responsabilidade do campo de busca global já especificado em `023-ux-melhorias-imoveis`.
- Ordenação de resultados de imóveis por popularidade de bairro (apenas os filtros do sidebar são ordenados por popularidade, não os cards de imóveis).
- Criação de um sistema completo de analytics de filtros — `property_count` é suficiente como proxy de popularidade nesta versão.
- Filtros de busca cross-categoria em tela mobile (sheet/modal) — este spec cobre apenas o sidebar em desktop; a experiência mobile é tratada em feature separada.
- Internacionalização dos labels de categoria ou dos badges.

View file

@ -0,0 +1,243 @@
# Tasks: Filtro de Busca Avançada — FilterSidebar
**Input**: Design documents from `/specs/024-filtro-busca-avancada/`
**Prerequisites**: plan.md ✅ · spec.md ✅ · data-model.md ✅ · contracts/api-catalog-enhancements.md ✅
**Branch**: `024-filtro-busca-avancada`
## Format: `[ID] [P?] [Story?] Description`
- **[P]**: pode ser executada em paralelo (arquivos distintos, sem dependência de tarefa incompleta)
- **[Story]**: a qual user story pertence (`US1`, `US2`, `US3`)
- Caminhos exatos incluídos em cada tarefa
---
## Phase 1: Foundational — Backend `property_count`
**Purpose**: Enriquecer os três endpoints de catálogo com `property_count` calculado via COUNT dinâmico (SQLAlchemy subquery). Sem migration — campo somente-leitura calculado em tempo de execução. Este é o pré-requisito de US3; US1 e US2 podem prosseguir em paralelo independentemente desta fase.
**⚠️ BLOQUEANTE para US3**: US3 não pode ser iniciada até T004 estar completo.
- [X] T001 Adicionar `property_count: int = 0` às classes `PropertyTypeOut`, `CityOut` e `NeighborhoodOut` em `backend/app/schemas/catalog.py`
- **Done**: Os três schemas Pydantic possuem o campo `property_count: int = 0` como atributo opcional de saída; `PropertyTypeOut.model_rebuild()` continua presente após a mudança; testes de serialização passam com o campo default.
- [X] T002 Atualizar `list_property_types()` em `backend/app/routes/catalog.py` para calcular `property_count` por subtype via subquery SQLAlchemy (`func.count + outerjoin + group_by`) e injetar no dict serializado
- **Detalhes**: importar `func` de `sqlalchemy` e `Property` de `app.models.property`; query plana de subtypes (`parent_id IS NOT NULL`) com `outerjoin(Property, (Property.subtype_id == PropertyType.id) & (Property.is_active.is_(True)))` + `group_by(PropertyType.id)`; construir `count_map: dict[int, int]`; substituir o `jsonify` atual por `serialize_category()` que injeta `property_count` em cada subtype via `count_map.get(sub["id"], 0)`; tipos pai mantêm `property_count: 0`.
- **Done**: `GET /api/v1/property-types` retorna cada subtype com `property_count >= 0`; tipos pai retornam `property_count: 0`; resposta é válida com ou sem imóveis ativos.
- [X] T003 [P] Atualizar `list_cities()` em `backend/app/routes/locations.py` para calcular `property_count` via `outerjoin(Property) + func.count + group_by`
- **Detalhes**: importar `func` de `sqlalchemy` e `Property` de `app.models.property`; substituir `City.query.order_by(...)` por `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()`; retornar `{**CityOut.model_validate(city).model_dump(), "property_count": cnt}`.
- **Done**: `GET /api/v1/cities` retorna cada cidade com `property_count >= 0`; cidades sem imóveis retornam `0`; ordenação `state ASC, name ASC` mantida.
- [X] T004 [P] Atualizar `list_neighborhoods()` em `backend/app/routes/locations.py` para calcular `property_count` via `outerjoin(Property) + func.count + group_by`
- **Detalhes**: substituir `Neighborhood.query` por `db.session.query(Neighborhood, func.count(Property.id).label("cnt")).outerjoin(Property, (Property.neighborhood_id == Neighborhood.id) & (Property.is_active.is_(True))).group_by(Neighborhood.id)`; preservar filtro `?city_id` existente aplicado antes de `.all()`; retornar `{**NeighborhoodOut.model_validate(n).model_dump(), "property_count": cnt}`.
- **Done**: `GET /api/v1/neighborhoods` e `GET /api/v1/neighborhoods?city_id=N` retornam bairros com `property_count >= 0`; filtro `city_id` ainda funciona.
**Checkpoint Phase 1**: Os três endpoints retornam `property_count` correto. Validar manualmente com `curl http://localhost:5000/api/v1/cities` e confirmar campo presente na resposta JSON.
---
## Phase 2: Foundational — Frontend Types
**Purpose**: Espelhar o campo `property_count` do backend nos tipos TypeScript. Backward-compatible (campo opcional). Pré-requisito de US3 no frontend.
- [X] T005 [P] Adicionar `property_count?: number` às interfaces `PropertyType`, `City` e `Neighborhood` em `frontend/src/types/catalog.ts`
- **Detalhes**: campo opcional (`?`) para backward-compatibility — componentes que não usam o campo continuam compilando sem alteração.
- **Done**: `frontend/src/types/catalog.ts` compila sem erros (`tsc --noEmit`); as três interfaces possuem `property_count?: number`; nenhum componente existente quebra.
**Checkpoint Phase 2**: `tsc --noEmit` passa. T005 pode ser executada em paralelo com a Phase 1 inteira.
---
## Phase 3: US2 — Estado Inicial Controlado das Seções (Priority: P1)
**Goal**: Ao carregar `/imoveis`, apenas a seção "Preço" está expandida. Seções com filtros ativos na URL são auto-expandidas. Estado não persistido entre sessões.
**Independent Test**: Carregar `/imoveis` sem parâmetros de URL e verificar que somente a seção "Preço" está expandida; todas as demais (Imobiliária, Localização, Tipo, Quartos, Área, Comodidades) estão colapsadas.
**Dependências**: nenhuma tarefa anterior é bloqueante para US2.
- [X] T006 [US2] Declarar o tipo `SectionKey` localmente em `frontend/src/components/FilterSidebar.tsx`
- **Detalhes**: adicionar antes da definição de `Section`; valores: `'imobiliaria' | 'localizacao' | 'tipo' | 'preco' | 'quartos' | 'area' | 'comodidades'`; não exportar.
- **Done**: tipo `SectionKey` declarado no arquivo; sem erro de compilação.
- [ ] T007 [US2] Implementar a função pura `initOpenSections(filters: PropertyFilters): Record<SectionKey, boolean>` localmente em `frontend/src/components/FilterSidebar.tsx`
- **Detalhes**: `preco: true` sempre; `imobiliaria: filters.imobiliaria_id != null`; `localizacao: filters.city_id != null || filters.neighborhood_id != null`; `tipo: filters.subtype_id != null`; `quartos: filters.bedrooms_min != null || filters.bathrooms_min != null || filters.parking_min != null`; `area: filters.area_min != null || filters.area_max != null`; `comodidades: (filters.amenity_ids?.length ?? 0) > 0`.
- **Done**: função declarada antes do componente; retorna objeto com todas as 7 chaves; `preco` sempre `true`.
- [X] T008 [US2] Converter o sub-componente `Section` para suportar modo controlled em `frontend/src/components/FilterSidebar.tsx`
- **Detalhes**: adicionar props opcionais `open?: boolean` e `onToggle?: () => void` à interface do componente; quando `open !== undefined`, usar `open` como state do accordion e chamar `onToggle` no click do botão; quando `open === undefined`, manter comportamento atual via `useState(defaultOpen)` (fallback uncontrolled); nenhum caller externo é quebrado.
- **Done**: `Section` aceita `open` e `onToggle` opcionais; modo uncontrolled continua funcionando; modo controlled expande/colapsa corretamente ao passar `open={true/false}` + `onToggle`.
- [X] T009 [US2] Adicionar estado `openSections` e helper `toggleSection` no componente principal `FilterSidebar` em `frontend/src/components/FilterSidebar.tsx`
- **Detalhes**: `const [openSections, setOpenSections] = useState<Record<SectionKey, boolean>>(() => initOpenSections(filters))`; `function toggleSection(key: SectionKey) { setOpenSections(prev => ({ ...prev, [key]: !prev[key] })) }`; adicionar também `function expandSection(key: SectionKey) { setOpenSections(prev => ({ ...prev, [key]: true })) }` (usado por US1 na T015).
- **Done**: estado `openSections` inicializado corretamente; `toggleSection` alterna o valor da chave; `expandSection` garante `true` sem alterar demais.
- [X] T010 [US2] Conectar todas as seções ao estado `openSections` em `frontend/src/components/FilterSidebar.tsx`
- **Detalhes**: cada `<Section>` deve receber `open={openSections['<key>']}` e `onToggle={() => toggleSection('<key>')}` — aplicar nas seções: Imobiliária (`imobiliaria`), Localização (`localizacao`), Tipo de imóvel (`tipo`), Preço (`preco`), Quartos e vagas (`quartos`), Área (`area`), Comodidades (`comodidades`); remover `defaultOpen` props onde `open` é passado.
- **Done**: todas as seções do sidebar são controlled; ao carregar sem URL params, apenas "Preço" está aberta; seções com filtros ativos na URL são abertas automaticamente; toggle manual funciona em cada seção.
**Checkpoint US2**: Carregar `/imoveis` — somente seção "Preço" expandida ✓. Carregar `/imoveis?city_id=1` — seções "Preço" e "Localização" expandidas ✓. Toggle manual colapsa/expande corretamente ✓.
---
## Phase 4: US1 — Campo de Busca Cross-Categoria (Priority: P1)
**Goal**: Campo de busca no topo do sidebar que filtra todos os itens do catálogo instantaneamente (debounce 200 ms), exibe sugestões agrupadas por categoria com highlight, seleciona ao clicar ou pressionar Enter, expande a seção relevante e limpa o campo.
**Independent Test**: Digitar "Copa" no campo de busca do sidebar e verificar que aparece sugestão "Copacabana" sob o grupo "Bairro"; clicar na sugestão e confirmar que o filtro de bairro é aplicado, o campo é limpo e a seção "Localização" é expandida.
**Dependências**: T008 e T009 (US2) devem estar completos para que `expandSection` esteja disponível.
- [X] T011 [US1] Declarar a interface `FilterSuggestion` e implementar a função `computeSuggestions()` localmente em `frontend/src/components/FilterSidebar.tsx`
- **Detalhes**: interface `FilterSuggestion { category: string; sectionKey: SectionKey; label: string; filterKey: keyof PropertyFilters; value: number | string | undefined; isAmenity?: boolean; amenityId?: number }`; função `computeSuggestions(query: string, propertyTypes: PropertyType[], cities: City[], neighborhoods: Neighborhood[], amenities: Amenity[]): FilterSuggestion[]`; normalização: `text.normalize('NFD').replace(/\p{Mn}/gu, '').toLowerCase()`; varrer `propertyTypes[*].subtypes` → categoria `"Tipo de imóvel"`, `sectionKey: 'tipo'`, `filterKey: 'subtype_id'`; `cities``"Cidade"`, `sectionKey: 'localizacao'`, `filterKey: 'city_id'`; `neighborhoods``"Bairro"`, `sectionKey: 'localizacao'`, `filterKey: 'neighborhood_id'`; `amenities``"Comodidade"`, `sectionKey: 'comodidades'`, `isAmenity: true`; retornar array vazio se `query.trim() === ''`.
- **Done**: `computeSuggestions('copa', [...], [...], [...], [...])` retorna ao menos uma entrada com `label: 'Copacabana'` e `category: 'Bairro'`; busca é case-insensitive e ignora acentos; query vazia retorna `[]`.
- [X] T012 [US1] Criar sub-componente local `SidebarSearchInput` em `frontend/src/components/FilterSidebar.tsx`
- **Detalhes**: props `{ value: string; onChange: (v: string) => void; disabled?: boolean; onKeyDown?: (e: React.KeyboardEvent) => void }`; renderiza `<input>` com placeholder `"Buscar filtro…"`, ícone de lupa (SVG inline), classes Tailwind: `w-full text-xs`; quando `disabled`: `cursor-not-allowed opacity-50`; `aria-label="Buscar filtro"`.
- **Done**: campo renderiza com placeholder correto; estado `disabled` visualmente diferenciado; sem dependências externas.
- [X] T013 [US1] Criar sub-componente local `SuggestionList` com navegação por teclado em `frontend/src/components/FilterSidebar.tsx`
- **Detalhes**: props `{ suggestions: FilterSuggestion[]; onSelect: (s: FilterSuggestion) => void; activeIndex: number }`; agrupar por `category` e renderizar cabeçalhos de grupo (`text-[10px] font-semibold text-textTertiary uppercase`); cada item: `<button>` com `data-index`, texto com highlight do termo buscado (fragmento `<mark>` com `bg-brand/20 text-brand`); item ativo recebe classe de destaque `bg-surface`; quando `suggestions.length === 0` e o caller passou query não-vazia, exibir `"Nenhum filtro encontrado"` em texto terciário; renderizado inline sob o campo (não é popup/portal).
- **Done**: grupos renderizados com cabeçalho; clique em item chama `onSelect`; item com `activeIndex` visualmente destacado; mensagem de "sem resultados" visível quando array vazio mas busca ativa.
- [X] T014 [US1] Adicionar estados `filterSearch`, `searchQuery` e `activeIndex` com debounce 200 ms no componente `FilterSidebar` em `frontend/src/components/FilterSidebar.tsx`
- **Detalhes**: `const [filterSearch, setFilterSearch] = useState('')`; `const [searchQuery, setSearchQuery] = useState('')`; `const [activeIndex, setActiveIndex] = useState(-1)`; `useEffect(() => { const t = setTimeout(() => { setSearchQuery(filterSearch); setActiveIndex(-1) }, 200); return () => clearTimeout(t) }, [filterSearch])`; `const suggestions = useMemo(() => computeSuggestions(searchQuery, propertyTypes, cities, neighborhoods, amenities), [searchQuery, propertyTypes, cities, neighborhoods, amenities])`.
- **Done**: alterar `filterSearch` só atualiza `searchQuery` após 200 ms; `suggestions` recomputa quando `searchQuery` muda; `activeIndex` reseta ao mudar query.
- [X] T015 [US1] Implementar a função `handleSuggestionSelect` e o handler de teclado `handleSearchKeyDown` em `frontend/src/components/FilterSidebar.tsx`
- **Detalhes**: `handleSuggestionSelect(s: FilterSuggestion)`: se `s.isAmenity`, chamar `toggleAmenity(s.amenityId!)`; senão, chamar `set({ [s.filterKey]: s.value })`; depois: `expandSection(s.sectionKey)`; `setFilterSearch('')`; `handleSearchKeyDown(e: React.KeyboardEvent)`: `ArrowDown``setActiveIndex(i => Math.min(i + 1, suggestions.length - 1))`; `ArrowUp``setActiveIndex(i => Math.max(i - 1, -1))`; `Enter` → se `activeIndex >= 0`, selecionar `suggestions[activeIndex]`; `Escape``setFilterSearch('')`.
- **Done**: clicar em sugestão aplica filtro + expande seção + limpa campo; navegação ↑↓ move destaque; Enter seleciona; Escape limpa; "Copacabana" selecionado aplica `city_id` ou `neighborhood_id` conforme categoria.
- [X] T016 [US1] Renderizar `SidebarSearchInput` e `SuggestionList` no topo do componente `FilterSidebar` em `frontend/src/components/FilterSidebar.tsx`
- **Detalhes**: inserir `<SidebarSearchInput>` após o bloco "Tipo de negócio" (selector Venda/Aluguel/Todos) e antes do primeiro `<div className="h-px bg-borderSubtle">` que divide as seções; abaixo, renderizar condicionalmente `{filterSearch.length > 0 && <SuggestionList suggestions={suggestions} onSelect={handleSuggestionSelect} activeIndex={activeIndex} />}`; passar `disabled={catalogLoading}` e `onKeyDown={handleSearchKeyDown}` ao `SidebarSearchInput`; quando `SuggestionList` está visível, as seções accordion continuam montadas (não desmontadas).
- **Done**: campo visível no topo do sidebar; sugestões aparecem ao digitar; desabilitado quando `catalogLoading=true`; sugestões somem ao limpar campo ou pressionar Escape.
**Checkpoint US1**: Digitar "apar" → sugestão "Apartamento" sob "Tipo de imóvel" aparece em < 50 ms . Clicar filtro aplicado, campo limpo, seção "Tipo de imóvel" expandida . Digitar "xxxxxxxxx" "Nenhum filtro encontrado" . Teclado Enter funciona .
---
## Phase 5: US3 — Truncamento, Popularidade e Badge (Priority: P2)
**Goal**: Cada seção mostra os 5 itens mais populares (por `property_count DESC`). Botão "Ver mais (N)" expande; "Ver menos" retrai. Os 3 mais populares exibem badge "Popular". Itens com filtro ativo são sempre visíveis.
**Independent Test**: Abrir seção "Bairros" no sidebar — apenas os 5 bairros com mais imóveis exibidos; badge "Popular" no primeiro; clicar "Ver mais" → todos visíveis; clicar "Ver menos" → volta a 5.
**Dependências**: T001T005 (property_count no backend e frontend types) + T008T010 (Section controlled, para integração harmoniosa).
- [X] T017 [US3] Criar sub-componente local `PopularBadge` em `frontend/src/components/FilterSidebar.tsx`
- **Detalhes**: sem props além de children opcionais; renderiza `<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold bg-brand/20 text-brand leading-none ml-1.5">Popular</span>`.
- **Done**: componente renderiza o badge com tokens de design corretos; sem dependências externas.
- [X] T018 [US3] Criar sub-componente local `TruncatedFilterList<T>` em `frontend/src/components/FilterSidebar.tsx`
- **Detalhes**: props genéricos `{ items: T[]; selectedId?: number | null; renderItem: (item: T, isPopular: boolean) => React.ReactNode; getId: (item: T) => number; getCount: (item: T) => number; topN?: number }`; lógica: ordenar por `getCount(item) DESC``sorted`; se item `selectedId` não está nos top `topN` (padrão 5), promovê-lo para início de `sorted` (sem alterar restante); `const [expanded, setExpanded] = useState(false)`; exibir `expanded ? sorted : sorted.slice(0, topN)`; `isPopular = index < 3` (após promoção, se o item promovido não era top-3, não recebe badge); botão "Ver mais (N)" / "Ver menos" somente quando `sorted.length > topN`; ao clicar "Ver menos", não fazer scroll automático.
- **Done**: com 8 itens exibe 5; botão "Ver mais (3)" aparece; clicar expande para 8; "Ver menos" recolhe; com 3 itens, botão não aparece; item selecionado fora do top-5 é promovido e visível sem clicar "Ver mais"; badge "Popular" nos índices 0, 1, 2 da lista ordenada.
- [X] T019 [US3] Aplicar `TruncatedFilterList` na seção "Tipo de imóvel" para os subtypes em `frontend/src/components/FilterSidebar.tsx`
- **Detalhes**: para cada categoria (`propertyType`), substituir o map de subtypes atual por `<TruncatedFilterList items={propertyType.subtypes} selectedId={filters.subtype_id ?? null} getId={s => s.id} getCount={s => s.property_count ?? 0} renderItem={(s, isPopular) => <...chip existente...>{isPopular && <PopularBadge />}</...>} />`; ordenação ocorre dentro de `TruncatedFilterList`.
- **Done**: seção "Tipo de imóvel" exibe top-5 subtypes por property_count; badge "Popular" nos 3 primeiros; "Ver mais" aparece quando subtypes > 5; subtype selecionado sempre visível.
- [X] T020 [P] [US3] Aplicar `TruncatedFilterList` na lista de bairros (`visibleNeighborhoods`) na seção "Localização" em `frontend/src/components/FilterSidebar.tsx`
- **Detalhes**: substituir o `visibleNeighborhoods.map(...)` existente por `<TruncatedFilterList items={visibleNeighborhoods} selectedId={filters.neighborhood_id ?? null} getId={n => n.id} getCount={n => n.property_count ?? 0} renderItem={(n, isPopular) => <...checkbox/chip existente...>{isPopular && <PopularBadge />}</...>} />`; aplica somente quando `visibleNeighborhoods.length > 0`.
- **Done**: bairros ordenados por property_count DESC; top-5 visíveis; badge nos 3 primeiros; bairro selecionado sempre visível.
- [X] T021 [P] [US3] Aplicar `TruncatedFilterList` nos grupos de comodidades na seção "Comodidades" em `frontend/src/components/FilterSidebar.tsx`
- **Detalhes**: para cada `AmenityGroup`, substituir o `amenities.filter(...).map(...)` por `<TruncatedFilterList>` passando `getCount={a => a.property_count ?? 0}`; como `Amenity` não tem `id` como filterKey direto (usa `amenity_ids` toggle), usar `selectedId={undefined}` (amenidades não têm seleção única) — botão "Ver mais" ainda funciona para truncar a lista longa.
- **Done**: cada grupo de comodidades exibe top-5; "Ver mais" expande; badge nos 3 mais populares por grupo; sem quebra no toggle de amenidades existente.
**Checkpoint US3**: Abrir `/imoveis` → seção "Bairros" mostra top-5 com badge "Popular" no primeiro ✓. Selecionar bairro fora do top-5 → reabre seção → bairro visível mesmo sem "Ver mais" ✓. `property_count: 0` para cidade sem imóveis ✓.
---
## Phase Final: Polish & Validação
**Purpose**: Verificações de qualidade cross-cutting após todas as user stories implementadas.
- [X] T022 [P] Verificar integração end-to-end em `frontend/src/components/FilterSidebar.tsx` e endpoints
- **Detalhes**: com o backend rodando, abrir `/imoveis`, inspecionar Network tab — confirmar que `GET /api/v1/cities`, `/api/v1/neighborhoods` e `/api/v1/property-types` retornam `property_count` em todos os itens; confirmar no sidebar que a ordenação por popularidade está correta e os badges aparecem nos 3 itens com maior contagem em cada seção.
- **Done**: nenhuma seção exibe itens sem `property_count`; ordenação no sidebar reflete os valores do backend; badge "Popular" nos 3 corretos por categoria.
- [X] T023 [P] Validar acessibilidade do `FilterSidebar` em `frontend/src/components/FilterSidebar.tsx`
- **Detalhes**: verificar `aria-expanded` correto nas seções (deve refletir `openSections[key]`); `SidebarSearchInput` tem `aria-label="Buscar filtro"`; `SuggestionList` itens têm `role="option"` ou são `<button>` com label descritivo; navegação ↑↓ não produz erro de console; `PopularBadge` tem `aria-label="Popular"` ou é `aria-hidden` conforme contexto.
- **Done**: nenhum erro de acessibilidade no console; `aria-expanded` correto em todas as seções; field de busca anunciado por screen reader; `tsc --noEmit` passa.
- [X] T024 Executar cenários do `quickstart.md` para a feature 024 (quando disponível)
- **Done**: todos os acceptance scenarios de US1, US2 e US3 do `spec.md` são verificados manualmente ou via quickstart; nenhuma regressão em funcionalidades existentes do sidebar.
---
## Dependencies & Execution Order
### Phase Dependencies
```
Phase 1 (Backend) ──────────────────────────────────────────────┐
Phase 2 (Frontend Types) ─────────────────────────────────────── ┤→ Phase 5 (US3)
Phase 3 (US2 — Section Controlled) ──────────────────────────── ┤→ Phase 5 (US3)
Phase 3 (US2 — Section Controlled) ──────────────────────────── ┘→ Phase 4 (US1)
Phase 4 (US1 — Busca) → Phase Final
Phase 5 (US3 — Truncamento) → Phase Final
```
- **Phase 1 e Phase 2**: independentes entre si — podem rodar em paralelo
- **Phase 3 (US2)**: independente de Phase 1/2 — pode iniciar imediatamente
- **Phase 4 (US1)**: depende de T009 (US2) para `expandSection`
- **Phase 5 (US3)**: depende de T001T005 (property_count) + T008 (Section controlled)
- **Phase Final**: depende de todas as fases anteriores
### User Story Dependencies
- **US2 (P1)**: sem dependências — pode iniciar imediatamente
- **US1 (P1)**: depende de US2 (T008, T009) para `expandSection`; pode ser implementada em paralelo com Phase 1/2
- **US3 (P2)**: depende de Phase 1 (T001T004), Phase 2 (T005) e US2 (T008T010)
### Parallel Opportunities (dentro das fases)
- **Phase 1**: T003 e T004 marcadas [P] — edições em funções distintas do mesmo arquivo (`locations.py`)
- **Phase 2**: T005 [P] — arquivo distinto de toda a Phase 1
- **Phase 5**: T020 e T021 marcadas [P] — seções distintas do componente
---
## Parallel Execution — MVP Scope
O MVP mínimo para entregar valor imediato é **US2 + US1** (ambas P1, sem dados novos de backend):
```
Sequência MVP (US2 → US1):
T006 → T007 → T008 → T009 → T010 (US2: ~2h)
T011 → T012 → T013 → T014 → T015 → T016 (US1: ~3h)
```
Para US3, adicionar antes:
```
Paralelo (pode rodar simultâneo ao MVP):
T001 → T002 (catalog.py)
T001 → T003 → T004 (locations.py)
T005 (catalog.ts)
```
---
## Implementation Strategy
1. **Iniciar com US2** (T006T010): menor risco, sem dados novos, entrega imediata de UX limpa
2. **Continuar com US1** (T011T016): depende de US2; lógica mais complexa mas totalmente local
3. **Backend em paralelo** (T001T004): pode ser feito enquanto US1/US2 avançam no frontend
4. **Finalizar com US3** (T017T021): após backend + US2; adiciona camada de popularidade
5. **Polish** (T022T024): validação final antes do merge
---
## Summary
| Fase | Tarefas | User Story | Paralelo |
|------|---------|------------|---------|
| Phase 1 — Backend | T001T004 | (foundational US3) | T003, T004 [P] |
| Phase 2 — Types | T005 | (foundational US3) | T005 [P] |
| Phase 3 — US2 | T006T010 | US2 | — |
| Phase 4 — US1 | T011T016 | US1 | — |
| Phase 5 — US3 | T017T021 | US3 | T020, T021 [P] |
| Phase Final | T022T024 | — | T022, T023 [P] |
| **Total** | **24 tarefas** | **3 user stories** | **5 paralelas** |