# 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` 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 │ → 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 { 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) | `` 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) | `Popular` 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` a partir de `PropertyFilters` — seção Preço sempre `true` | | `openSections` state | NEW | `useState>` inicializado via `initOpenSections(filters)` | | `filterSearch` / `searchQuery` state | NEW | `useState` 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 │ │ │ │ │ │ │ │ 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