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

24 KiB
Raw Blame History

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.

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

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

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

  • 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 [].
  • 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.
  • 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.
  • 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.
  • 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): ArrowDownsetActiveIndex(i => Math.min(i + 1, suggestions.length - 1)); ArrowUpsetActiveIndex(i => Math.max(i - 1, -1)); Enter → se activeIndex >= 0, selecionar suggestions[activeIndex]; EscapesetFilterSearch('').
    • 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.
  • 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).

  • 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.
  • 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) DESCsorted; 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.
  • 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.
  • 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.
  • 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.

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