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

22 KiB

Implementation Plan: Filtro de Busca Avançada — FilterSidebar

Branch: 024-filtro-busca-avancada | Date: 2026-04-20 | Spec: 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)

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)

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

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 = trueSidebarSearchInput 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