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

243 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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** |