sass-imobiliaria/.specify/features/016-analytics-dashboard/tasks.md

18 KiB
Raw Permalink Blame History

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 PageViews 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<AnalyticsSummary> implementada como api.get<AnalyticsSummary>(\/admin/analytics/summary?days=${days}`).then(r => r.data)usando a instânciaapiimportada 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<AnalyticsSummary | null>(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, <p className="text-textSecondary text-sm mb-1">{label}</p>, <p className="text-textPrimary text-2xl font-semibold">{value}</p>; 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 <polyline> 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"; <polygon> de área preenchendo até y=110 com fill="#5e6ad2" fillOpacity="0.1"; labels do eixo X: renderizar data a cada 7ª posição em <text> com fontSize="9" fill="#9ca3af"; quando maxCount === 0 exibir <text x="300" y="65" textAnchor="middle" fill="#9ca3af" fontSize="12">Sem dados</text> 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 <Route path="analytics" element={<AdminAnalyticsPage />} /> dentro do bloco <Route path="/admin" element={<AdminRoute>...}> 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 PageViews com paths e property_ids 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<TopPagesResponse> e getTopProperties(days: number): Promise<TopPropertiesResponse> 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<TopPagesResponse | null>(null) e const [topProperties, setTopProperties] = useState<TopPropertiesResponse | null>(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 <img src={item.cover_photo || '/placeholder.jpg'} className="w-10 h-10 object-cover rounded" /> + 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:
    @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 1test_summary_no_token_returns_401: GET /api/v1/admin/analytics/summary sem header Authorizationassert resp.status_code == 401

    Cenário 2test_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 3test_summary_without_data_returns_all_zeros: sem PageViews, GET /summary?days=7today == 0, this_week == 0, this_month == 0, period_total == 0, todos os count em daily_series são 0

    Cenário 4test_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 5test_top_properties_omits_missing_property: inserir PageView com property_id = uuid4() (imóvel inexistente); GET /top-properties?days=30data["items"] == []

    Cenário 6test_hook_does_not_track_admin_routes: GET /api/v1/admin/analytics/summary com token admin → PageView.query.count() == 0

    Cenário 7test_hook_does_not_track_auth_routes: POST /api/v1/auth/login com body JSON → PageView.query.count() == 0

    Cenário 8test_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 9test_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 (T001T011): Dashboard com cards e gráfico — vazio mas sem erros
  2. +US2 (T012T015): 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 (T002T006)
US1 (P1) 5 (T007T011)
US2 (P2) 4 (T012T015)
US3 (P3) 1 (T016)
Tests & Polish 1 (T017)