# Tasks: Analytics Dashboard (Admin) **Input**: Design documents from `.specify/features/016-analytics-dashboard/` **Branch**: `016-analytics-dashboard` **Prerequisites**: plan.md ✓, spec.md ✓ **Organization**: Tasks organizadas por user story para permitir implementação e teste independentes de cada história. ## Format: `[ID] [P?] [Story] Description` - **[P]**: Pode rodar em paralelo (arquivos diferentes, sem dependências incompletas) - **[Story]**: User story correspondente ([US1], [US2], [US3]) - Todos os caminhos são relativos à raiz do repositório --- ## Phase 1: Setup **Purpose**: Configuração obrigatória que deve existir antes de qualquer implementação. - [ ] T001 Add `IP_SALT` env variable to `backend/app/config.py` — em `BaseConfig` adicionar `IP_SALT = os.environ.get("IP_SALT", "")` e em `TestingConfig` adicionar `IP_SALT = "test-salt-analytics-016"` para evitar falha silenciosa durante testes --- ## Phase 2: Foundational (Blocking Prerequisites) **Purpose**: Infraestrutura compartilhada por todas as user stories — modelo, migração, schemas e blueprint. **⚠️ CRITICAL**: Nenhuma user story pode ser implementada até esta fase estar completa. - [ ] T002 Create `backend/app/models/page_view.py` — modelo SQLAlchemy `PageView` com `__tablename__ = "page_views"` e colunas: `id` (`db.UUID(as_uuid=True)`, PK, `default=uuid.uuid4`), `path` (`db.String(500)`, not null), `property_id` (`db.UUID(as_uuid=True)`, nullable, **sem ForeignKey**), `accessed_at` (`db.DateTime`, not null, `default=datetime.utcnow`), `ip_hash` (`db.String(64)`, not null), `user_agent` (`db.String(500)`, nullable); declarar índices via `__table_args__` com `db.Index("ix_page_views_accessed_at", "accessed_at")` e `db.Index("ix_page_views_property_id", "property_id")` - [ ] T003 [P] Create `backend/app/schemas/analytics.py` — schemas Pydantic v2 (`from pydantic import BaseModel`): `DailyPoint(date: str, count: int)`, `AnalyticsSummary(today: int, this_week: int, this_month: int, period_total: int, daily_series: list[DailyPoint])`, `TopPageItem(path: str, count: int)`, `TopPagesResponse(items: list[TopPageItem])`, `TopPropertyItem(property_id: str, title: str, cover_photo: str | None, count: int)`, `TopPropertiesResponse(items: list[TopPropertyItem])` - [ ] T004 [P] Create `backend/app/routes/analytics.py` — scaffold do blueprint: `analytics_bp = Blueprint("analytics", __name__)`; importar `require_admin` de `app.utils.auth`, `db` de `app.extensions`, `PageView` de `app.models.page_view`, todos os schemas de `app.schemas.analytics`; criar as 3 funções de endpoint decoradas com `@analytics_bp.get(...)` e `@require_admin` ainda retornando `({}, 200)` como placeholder (corpo implementado em T007, T012, T013) - [ ] T005 Update `backend/app/__init__.py` — na seção de import de models adicionar `from app.models import page_view as _page_view_models # noqa: F401`; na seção de registro de blueprints adicionar `from app.routes.analytics import analytics_bp` e `app.register_blueprint(analytics_bp, url_prefix="/api/v1/admin")` - [ ] T006 Generate and apply Alembic migration — dentro de `backend/` executar `alembic revision --autogenerate -m "add_page_views"`; abrir o arquivo gerado em `backend/migrations/versions/` e verificar/corrigir para que contenha: `op.create_table("page_views", sa.Column("id", sa.UUID(), server_default=sa.text("gen_random_uuid()"), nullable=False), sa.Column("path", sa.String(500), nullable=False), sa.Column("property_id", sa.UUID(), nullable=True), sa.Column("accessed_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False), sa.Column("ip_hash", sa.String(64), nullable=False), sa.Column("user_agent", sa.String(500), nullable=True), sa.PrimaryKeyConstraint("id"))` e os dois `op.create_index`; no `downgrade` adicionar `op.drop_index` para ambos os índices antes de `op.drop_table`; executar `alembic upgrade head` **Checkpoint**: Modelo registrado no ORM, tabela criada no banco, blueprint montado em `/api/v1/admin`. --- ## Phase 3: User Story 1 — Visualizar Métricas de Acesso (Priority: P1) 🎯 MVP **Goal**: Administrador acessa `/admin/analytics`, vê cards com totais de hoje/semana/mês, seleciona período (7d/30d/90d) e visualiza gráfico de linha SVG com a série diária do período. **Independent Test**: Inserir `PageView`s diretamente via fixture, chamar `GET /api/v1/admin/analytics/summary?days=30` com token admin e verificar resposta com shape correto (`today`, `this_week`, `this_month`, `period_total`, `daily_series` com exatamente 30 entradas); na UI acessar `/admin/analytics` e confirmar que cards e gráfico renderizam (com zeros se DB vazio). ### Implementation for User Story 1 - [ ] T007 [US1] Implement `GET /summary` in `backend/app/routes/analytics.py` — `@analytics_bp.get("/analytics/summary")` com `@require_admin`; ler query param `days = int(request.args.get("days", 30))`; calcular os 4 contadores via SQLAlchemy: `today` = `db.session.query(func.count(PageView.id)).filter(func.date(PageView.accessed_at) == date.today()).scalar()`, `this_week` = count com `PageView.accessed_at >= datetime.utcnow().replace(hour=0,minute=0,second=0) - timedelta(days=datetime.utcnow().weekday())`, `this_month` = count com dia 1 do mês corrente, `period_total` = count com `PageView.accessed_at >= datetime.utcnow() - timedelta(days=days)`; para `daily_series`: query `GROUP BY func.date(PageView.accessed_at)` within the period → produzir dict `{date_str: count}` → iterar `range(days-1, -1, -1)` construindo lista de `DailyPoint` (count=0 se data ausente); retornar `jsonify(AnalyticsSummary(...).model_dump())` - [ ] T008 [P] [US1] Create `frontend/src/services/analytics.ts` — exportar interfaces TypeScript: `DailyPoint`, `AnalyticsSummary`, `TopPageItem`, `TopPagesResponse`, `TopPropertyItem`, `TopPropertiesResponse`; exportar função `getAnalyticsSummary(days: number): Promise` implementada como `api.get(\`/admin/analytics/summary?days=\${days}\`).then(r => r.data)` usando a instância `api` importada de `./api` - [ ] T009 [P] [US1] Create `frontend/src/pages/admin/AdminAnalyticsPage.tsx` — estado `const [days, setDays] = useState<7|30|90>(30)` e `const [summary, setSummary] = useState(null)` e `const [isLoading, setIsLoading] = useState(true)`; `useEffect` que chama `getAnalyticsSummary(days)` ao montar e ao trocar `days`; componente inline `PeriodFilter`: 3 botões `[7, 30, 90]` com classes Tailwind ativo=`bg-brand text-white rounded-md px-3 py-1 text-sm font-medium`, inativo=`bg-surface text-textSecondary rounded-md px-3 py-1 text-sm`; componente inline `MetricCard({ label, value })`: `div` com `bg-panel border border-borderSubtle shadow-card rounded-lg p-4`, `

{label}

`, `

{value}

`; renderizar 3 cards (Hoje, Esta Semana, Este Mês) e um card total do período; componente inline `LineChart({ series }: { series: DailyPoint[] })`: SVG `viewBox="0 0 600 120"` com `` cujos pontos são calculados como `x = (i / (series.length-1)) * 580 + 10`, `y = 110 - (count / maxCount) * 100` (tratando `maxCount=0` como 1 para evitar NaN), `stroke="#7170ff" strokeWidth="2" fill="none"`; `` de área preenchendo até y=110 com `fill="#5e6ad2" fillOpacity="0.1"`; labels do eixo X: renderizar data a cada 7ª posição em `` com `fontSize="9" fill="#9ca3af"`; quando `maxCount === 0` exibir `Sem dados` no centro; skeleton com `animate-pulse div` do Tailwind enquanto `isLoading === true` - [ ] T010 [US1] Add `{ to: '/admin/analytics', label: 'Analytics' }` to `adminNavItems` array in `frontend/src/components/Navbar.tsx` — inserir como último item do array (após `{ to: '/admin/amenidades', label: 'Amenidades' }`) - [ ] T011 [US1] Register route in `frontend/src/App.tsx` — adicionar `import AdminAnalyticsPage from './pages/admin/AdminAnalyticsPage'` no bloco de imports de páginas admin; adicionar `} />` dentro do bloco `...}>` junto com as outras rotas admin existentes **Checkpoint**: US1 totalmente funcional. Admin acessa `/admin/analytics`, vê cards e gráfico (zeros se nenhum PageView no banco). Link "Analytics" aparece na navbar do painel admin. --- ## Phase 4: User Story 2 — Consultar Páginas e Imóveis Mais Acessados (Priority: P2) **Goal**: Administrador vê tabelas com top-10 páginas mais acessadas e top-10 imóveis mais visualizados para o período selecionado. **Independent Test**: Inserir `PageView`s com paths e `property_id`s distintos; chamar `GET /api/v1/admin/analytics/top-pages?days=30` e `GET /api/v1/admin/analytics/top-properties?days=30` e verificar listas com `path+count` e `property_id+title+cover_photo+count` ordenadas DESC; confirmar que itens com `property_id` inexistente são omitidos de `/top-properties`. ### Implementation for User Story 2 - [ ] T012 [US2] Implement `GET /top-pages` in `backend/app/routes/analytics.py` — `@analytics_bp.get("/analytics/top-pages")` com `@require_admin`; ler `days`; query SQLAlchemy: `db.session.query(PageView.path, func.count(PageView.id).label("count")).filter(PageView.accessed_at >= datetime.utcnow() - timedelta(days=days)).group_by(PageView.path).order_by(func.count(PageView.id).desc()).limit(10).all()`; mapear para `list[TopPageItem]`; retornar `jsonify(TopPagesResponse(items=...).model_dump())` - [ ] T013 [US2] Implement `GET /top-properties` in `backend/app/routes/analytics.py` — `@analytics_bp.get("/analytics/top-properties")` com `@require_admin`; ler `days`; query SQLAlchemy: selecionar `PageView.property_id`, `func.count(PageView.id).label("count")` WHERE `PageView.property_id != None` AND no período, `GROUP BY PageView.property_id`, `ORDER BY count DESC`, `LIMIT 10`; para cada resultado fazer `Property.query.get(row.property_id)` (pular se None — preserva comportamento de imóvel deletado); obter `cover_photo` via `PropertyPhoto.query.filter_by(property_id=prop.id).order_by(PropertyPhoto.display_order).first()`; montar `TopPropertyItem` e retornar `jsonify(TopPropertiesResponse(items=...).model_dump())`; importar `Property` de `app.models.property` e `PropertyPhoto` de `app.models.property` - [ ] T014 [P] [US2] Add typed functions to `frontend/src/services/analytics.ts` — adicionar `getTopPages(days: number): Promise` e `getTopProperties(days: number): Promise` seguindo o mesmo padrão de `getAnalyticsSummary` - [ ] T015 [US2] Add ranking tables to `frontend/src/pages/admin/AdminAnalyticsPage.tsx` — adicionar estado `const [topPages, setTopPages] = useState(null)` e `const [topProperties, setTopProperties] = useState(null)`; atualizar o `useEffect` para disparar os 3 fetches em paralelo via `Promise.all([getAnalyticsSummary(days), getTopPages(days), getTopProperties(days)])`; componente inline `TopPagesTable({ items })`: `table` com `bg-panel rounded-lg overflow-hidden w-full`, cabeçalho `text-textSecondary text-xs uppercase`, linhas com `text-textPrimary text-sm even:bg-surface`, colunas "Página" + "Acessos"; componente inline `TopPropertiesTable({ items })`: mesma estrutura, coluna com imagem `` + título + visualizações; exibir mensagem "Sem dados para o período" quando listas estiverem vazias; renderizar as duas tabelas na página abaixo do gráfico em layout grid `grid-cols-1 lg:grid-cols-2 gap-6` **Checkpoint**: US1 e US2 funcionais. Admin vê cards, gráfico e tabelas de ranking em uma única página com filtro de período unificado. --- ## Phase 5: User Story 3 — Rastreamento Automático de Visitas Públicas (Priority: P3) **Goal**: Cada GET a uma rota pública cria um `PageView` com IP anonimizado e `property_id` quando aplicável, sem bloquear a resposta ao visitante. **Independent Test**: No ambiente de teste, fazer `client.get("/api/v1/properties")` e verificar `PageView.query.count() == 1` com `path == "/api/v1/properties"` e `ip_hash` de 64 chars; fazer `client.get("/api/v1/admin/analytics/summary", headers=...)` e confirmar que o count NÃO aumenta. ### Implementation for User Story 3 - [ ] T016 [US3] Add `_track_page_view` before_request hook in `backend/app/__init__.py` — adicionar `import hashlib` e `import re` no topo do arquivo (junto aos outros imports de stdlib); dentro de `create_app()`, APÓS o registro de todos os blueprints, adicionar: ```python @app.before_request def _track_page_view(): try: if request.method != "GET": return path = request.path excluded = ("/api/v1/admin", "/api/v1/auth", "/static") if any(path.startswith(p) for p in excluded): return ip_raw = request.remote_addr or "" salt = current_app.config.get("IP_SALT", "") ip_hash = hashlib.sha256((ip_raw + salt).encode()).hexdigest() property_id = None m = re.match(r"^/api/v1/properties/([0-9a-f-]{36})$", path) if m: property_id = m.group(1) pv = PageView( path=path[:500], ip_hash=ip_hash, user_agent=(request.user_agent.string or "")[:500], property_id=property_id, ) db.session.add(pv) db.session.commit() except Exception: db.session.rollback() ``` Importar `PageView` de `app.models.page_view` no topo da função `create_app` (após os outros imports de modelo já existentes); importar `request` e `current_app` de `flask` caso não estejam já importados **Checkpoint**: Rastreamento ativo. Acessar qualquer rota pública GET cria PageView no banco; `/api/v1/admin/*` e `/api/v1/auth/*` não são registrados; falha no commit não propaga exception ao visitante. --- ## Phase 6: Tests & Polish **Purpose**: Cobrir os 9 cenários de teste especificados no plan.md. - [ ] T017 Create `backend/tests/test_analytics.py` — helper `_make_admin_token(app)` que cria um `ClientUser(role="admin")` no banco e assina um JWT com `app.config["JWT_SECRET_KEY"]`; helper `_insert_page_view(db, path, accessed_at=None, property_id=None)` para inserção direta; implementar os 9 testes: **Cenário 1** — `test_summary_no_token_returns_401`: `GET /api/v1/admin/analytics/summary` sem header `Authorization` → `assert resp.status_code == 401` **Cenário 2** — `test_summary_with_admin_token_returns_200_and_correct_shape`: criar admin, obter token, `GET /summary?days=7` → 200; verificar `data.keys()` contém `today, this_week, this_month, period_total, daily_series`; `len(data["daily_series"]) == 7`; todos os items de `daily_series` têm chaves `date` e `count` **Cenário 3** — `test_summary_without_data_returns_all_zeros`: sem PageViews, `GET /summary?days=7` → `today == 0`, `this_week == 0`, `this_month == 0`, `period_total == 0`, todos os `count` em `daily_series` são 0 **Cenário 4** — `test_top_pages_respects_days_filter`: inserir 2 PageViews com `accessed_at = datetime.utcnow() - timedelta(days=60)` e 1 recente; `GET /top-pages?days=7` → somente o recente aparece no resultado **Cenário 5** — `test_top_properties_omits_missing_property`: inserir PageView com `property_id = uuid4()` (imóvel inexistente); `GET /top-properties?days=30` → `data["items"] == []` **Cenário 6** — `test_hook_does_not_track_admin_routes`: `GET /api/v1/admin/analytics/summary` com token admin → `PageView.query.count() == 0` **Cenário 7** — `test_hook_does_not_track_auth_routes`: `POST /api/v1/auth/login` com body JSON → `PageView.query.count() == 0` **Cenário 8** — `test_hook_tracks_public_get_route`: `client.get("/api/v1/properties")` → `PageView.query.count() == 1`; verificar `pv.path == "/api/v1/properties"` e `len(pv.ip_hash) == 64` **Cenário 9** — `test_hook_swallows_db_exception_without_blocking_request`: usar `unittest.mock.patch("app.extensions.db.session.commit", side_effect=Exception("DB down"))`; `client.get("/api/v1/properties")` → `assert resp.status_code == 200` (a requisição não é bloqueada) --- ## Dependencies ``` T001 → T002,T003,T004 (paralelo) → T005 → T006 → T007 [US1 backend] → T008,T009 [US1 frontend, paralelo] T007 → T012,T013 [US2 backend] T008 → T014 [US2 serviço] T009 → T015 [US2 UI] T005 → T016 [US3 hook] T006,T007,T012,T013,T016 → T017 [Tests] T009 → T010,T011 [Nav + Router] ``` ## Parallel Execution per Phase | Fase | Tarefas Paralelas | |------|------------------| | Phase 2 | T003 ∥ T004 (schemas e blueprint são arquivos distintos independentes) | | Phase 3 | T008 ∥ T009 (serviço frontend e página são independentes do backend e entre si) | | Phase 4 | T012 ∥ T013 (dois endpoints distintos no mesmo arquivo, mas sem dependência entre si); T014 ∥ T012,T013 | ## Implementation Strategy **MVP (somente US1)** — ordem mínima para entregar o dashboard funcional com zeros: > T001 → T002 → T003,T004 → T005 → T006 → T007 → T008,T009 → T010 → T011 Com US1 completo o admin já tem uma página funcional; os dados serão zero até US3 ser implementado. **Entrega incremental**: 1. **MVP** (T001–T011): Dashboard com cards e gráfico — vazio mas sem erros 2. **+US2** (T012–T015): Tabelas de top páginas e imóveis aparecem na mesma página 3. **+US3** (T016): Rastreamento ativo — dados reais começam a popular o dashboard 4. **+Tests** (T017): Cobertura dos 9 cenários de aceitação **Total de tarefas**: 17 | Fase | Tarefas | |------|---------| | Setup | 1 (T001) | | Foundational | 5 (T002–T006) | | US1 (P1) | 5 (T007–T011) | | US2 (P2) | 4 (T012–T015) | | US3 (P3) | 1 (T016) | | Tests & Polish | 1 (T017) |