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

334 lines
22 KiB
Markdown

# 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 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}` 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` 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 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 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