18 KiB
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_SALTenv variable tobackend/app/config.py— emBaseConfigadicionarIP_SALT = os.environ.get("IP_SALT", "")e emTestingConfigadicionarIP_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 SQLAlchemyPageViewcom__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__comdb.Index("ix_page_views_accessed_at", "accessed_at")edb.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__); importarrequire_admindeapp.utils.auth,dbdeapp.extensions,PageViewdeapp.models.page_view, todos os schemas deapp.schemas.analytics; criar as 3 funções de endpoint decoradas com@analytics_bp.get(...)e@require_adminainda retornando({}, 200)como placeholder (corpo implementado em T007, T012, T013) -
T005 Update
backend/app/__init__.py— na seção de import de models adicionarfrom app.models import page_view as _page_view_models # noqa: F401; na seção de registro de blueprints adicionarfrom app.routes.analytics import analytics_bpeapp.register_blueprint(analytics_bp, url_prefix="/api/v1/admin") -
T006 Generate and apply Alembic migration — dentro de
backend/executaralembic revision --autogenerate -m "add_page_views"; abrir o arquivo gerado embackend/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 doisop.create_index; nodowngradeadicionarop.drop_indexpara ambos os índices antes deop.drop_table; executaralembic 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 /summaryinbackend/app/routes/analytics.py—@analytics_bp.get("/analytics/summary")com@require_admin; ler query paramdays = 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 comPageView.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 comPageView.accessed_at >= datetime.utcnow() - timedelta(days=days); paradaily_series: queryGROUP BY func.date(PageView.accessed_at)within the period → produzir dict{date_str: count}→ iterarrange(days-1, -1, -1)construindo lista deDailyPoint(count=0 se data ausente); retornarjsonify(AnalyticsSummary(...).model_dump()) -
T008 [P] [US1] Create
frontend/src/services/analytics.ts— exportar interfaces TypeScript:DailyPoint,AnalyticsSummary,TopPageItem,TopPagesResponse,TopPropertyItem,TopPropertiesResponse; exportar funçãogetAnalyticsSummary(days: number): Promise<AnalyticsSummary>implementada comoapi.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— estadoconst [days, setDays] = useState<7|30|90>(30)econst [summary, setSummary] = useState<AnalyticsSummary | null>(null)econst [isLoading, setIsLoading] = useState(true);useEffectque chamagetAnalyticsSummary(days)ao montar e ao trocardays; componente inlinePeriodFilter: 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 inlineMetricCard({ label, value }):divcombg-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 inlineLineChart({ series }: { series: DailyPoint[] }): SVGviewBox="0 0 600 120"com<polyline>cujos pontos são calculados comox = (i / (series.length-1)) * 580 + 10,y = 110 - (count / maxCount) * 100(tratandomaxCount=0como 1 para evitar NaN),stroke="#7170ff" strokeWidth="2" fill="none";<polygon>de área preenchendo até y=110 comfill="#5e6ad2" fillOpacity="0.1"; labels do eixo X: renderizar data a cada 7ª posição em<text>comfontSize="9" fill="#9ca3af"; quandomaxCount === 0exibir<text x="300" y="65" textAnchor="middle" fill="#9ca3af" fontSize="12">Sem dados</text>no centro; skeleton comanimate-pulse divdo Tailwind enquantoisLoading === true -
T010 [US1] Add
{ to: '/admin/analytics', label: 'Analytics' }toadminNavItemsarray infrontend/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— adicionarimport 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-pagesinbackend/app/routes/analytics.py—@analytics_bp.get("/analytics/top-pages")com@require_admin; lerdays; 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 paralist[TopPageItem]; retornarjsonify(TopPagesResponse(items=...).model_dump()) -
T013 [US2] Implement
GET /top-propertiesinbackend/app/routes/analytics.py—@analytics_bp.get("/analytics/top-properties")com@require_admin; lerdays; query SQLAlchemy: selecionarPageView.property_id,func.count(PageView.id).label("count")WHEREPageView.property_id != NoneAND no período,GROUP BY PageView.property_id,ORDER BY count DESC,LIMIT 10; para cada resultado fazerProperty.query.get(row.property_id)(pular se None — preserva comportamento de imóvel deletado); obtercover_photoviaPropertyPhoto.query.filter_by(property_id=prop.id).order_by(PropertyPhoto.display_order).first(); montarTopPropertyIteme retornarjsonify(TopPropertiesResponse(items=...).model_dump()); importarPropertydeapp.models.propertyePropertyPhotodeapp.models.property -
T014 [P] [US2] Add typed functions to
frontend/src/services/analytics.ts— adicionargetTopPages(days: number): Promise<TopPagesResponse>egetTopProperties(days: number): Promise<TopPropertiesResponse>seguindo o mesmo padrão degetAnalyticsSummary -
T015 [US2] Add ranking tables to
frontend/src/pages/admin/AdminAnalyticsPage.tsx— adicionar estadoconst [topPages, setTopPages] = useState<TopPagesResponse | null>(null)econst [topProperties, setTopProperties] = useState<TopPropertiesResponse | null>(null); atualizar ouseEffectpara disparar os 3 fetches em paralelo viaPromise.all([getAnalyticsSummary(days), getTopPages(days), getTopProperties(days)]); componente inlineTopPagesTable({ items }):tablecombg-panel rounded-lg overflow-hidden w-full, cabeçalhotext-textSecondary text-xs uppercase, linhas comtext-textPrimary text-sm even:bg-surface, colunas "Página" + "Acessos"; componente inlineTopPropertiesTable({ 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 gridgrid-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_viewbefore_request hook inbackend/app/__init__.py— adicionarimport hashlibeimport reno topo do arquivo (junto aos outros imports de stdlib); dentro decreate_app(), APÓS o registro de todos os blueprints, adicionar:
Importar@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()PageViewdeapp.models.page_viewno topo da funçãocreate_app(após os outros imports de modelo já existentes); importarrequestecurrent_appdeflaskcaso 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 umClientUser(role="admin")no banco e assina um JWT comapp.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/summarysem headerAuthorization→assert resp.status_code == 401Cenário 2 —
test_summary_with_admin_token_returns_200_and_correct_shape: criar admin, obter token,GET /summary?days=7→ 200; verificardata.keys()contémtoday, this_week, this_month, period_total, daily_series;len(data["daily_series"]) == 7; todos os items dedaily_seriestêm chavesdateecountCená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 oscountemdaily_seriessão 0Cenário 4 —
test_top_pages_respects_days_filter: inserir 2 PageViews comaccessed_at = datetime.utcnow() - timedelta(days=60)e 1 recente;GET /top-pages?days=7→ somente o recente aparece no resultadoCenário 5 —
test_top_properties_omits_missing_property: inserir PageView comproperty_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/summarycom token admin →PageView.query.count() == 0Cenário 7 —
test_hook_does_not_track_auth_routes:POST /api/v1/auth/logincom body JSON →PageView.query.count() == 0Cenário 8 —
test_hook_tracks_public_get_route:client.get("/api/v1/properties")→PageView.query.count() == 1; verificarpv.path == "/api/v1/properties"elen(pv.ip_hash) == 64Cenário 9 —
test_hook_swallows_db_exception_without_blocking_request: usarunittest.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:
- MVP (T001–T011): Dashboard com cards e gráfico — vazio mas sem erros
- +US2 (T012–T015): Tabelas de top páginas e imóveis aparecem na mesma página
- +US3 (T016): Rastreamento ativo — dados reais começam a popular o dashboard
- +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) |