14 KiB
Implementation Plan: Analytics Dashboard (Admin)
Branch: 016-analytics-dashboard | Date: 2026-04-14 | Spec: spec.md
Input: Feature specification from .specify/features/016-analytics-dashboard/spec.md
Summary
Adicionar um dashboard de analytics ao painel admin que exibe métricas de acesso ao site (total por dia/semana/mês, gráfico de linha dos últimos N dias, top 10 páginas e top 10 imóveis mais visitados). O rastreamento é feito via before_request hook no Flask — sem bibliotecas de charting externas no frontend (SVG/CSS com Tailwind tokens existentes).
Technical Context
Language/Version: Python 3.12 (backend) · TypeScript 5.5 (frontend)
Primary Dependencies: Flask 3.x, SQLAlchemy 2.x, Pydantic v2, PyJWT (backend) · React 18, Tailwind CSS 3.4, Axios (frontend)
Storage: PostgreSQL 16 — nova tabela page_views
Testing: pytest (backend) · Vite build check (frontend)
Target Platform: Web service (REST API) + SPA
Project Type: Web application (backend + frontend)
Performance Goals: Resposta do dashboard < 3 s (SC-001); overhead do hook < 50 ms por requisição (SC-002)
Constraints: IP jamais persistido em claro — somente SHA-256 hash (FR-010, SC-005); rastreamento falha silenciosamente sem bloquear visitante (FR-009); somente admins autenticados acessam endpoints (FR-011)
Scale/Scope: Volume proporcional ao tráfego; top-10 fixo; sem paginação em v1; sem polling automático em v1
Constitution Check
GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.
| Princípio | Status | Observação |
|---|---|---|
| I. Design-First | PASS | Frontend usa tokens Tailwind do sistema (bg-canvas, bg-panel, bg-surface, text-textPrimary, border-borderPrimary, bg-brand etc.); gráfico de linha via SVG inline — sem libs externas |
| II. Separação de Responsabilidades | PASS | Flask expõe 3 endpoints REST JSON puros; React consome via Axios; sem SSR |
| III. Spec-Driven | PASS | spec.md com user stories e acceptance scenarios aprovado; spec → plan → tasks → implement |
| IV. Integridade de Dados | PASS | Migração Alembic para page_views; SQLAlchemy ORM para queries; Pydantic v2 nos schemas; property_id nullable sem FK (preserva histórico se imóvel deletado) |
| V. Segurança | PASS | Endpoints /api/v1/admin/analytics/* protegidos por require_admin; IP armazenado como SHA-256 hash irreversível com salt; rotas admin e auth excluídas do rastreamento (FR-003) |
| VI. Simplicidade | PASS | before_request no app factory sem camada extra; SVG puro sem Recharts/Chart.js; 3 endpoints de agregação SQL simples |
Constitution Check Post-Design: PASS — nenhuma violação identificada após Phase 1.
Project Structure
Documentation (this feature)
.specify/features/016-analytics-dashboard/
├── plan.md # Este arquivo
├── research.md # Phase 0 output
├── data-model.md # Phase 1 output
├── contracts/
│ └── analytics-api.md # Phase 1 output
├── quickstart.md # Phase 1 output
└── tasks.md # Phase 2 output (/speckit.tasks — NÃO criado por /speckit.plan)
Source Code (repository root)
backend/
├── app/
│ ├── models/
│ │ └── page_view.py # NOVO — modelo PageView (tabela page_views)
│ ├── routes/
│ │ └── analytics.py # NOVO — Blueprint analytics_bp, 3 endpoints GET
│ ├── schemas/
│ │ └── analytics.py # NOVO — Pydantic schemas de resposta
│ └── __init__.py # MODIFICADO — importa model, registra blueprint, adds before_request hook
├── migrations/versions/
│ └── xxxx_add_page_views.py # NOVO — Alembic migration up/down
└── tests/
└── test_analytics.py # NOVO — 9 cenários de teste
frontend/
└── src/
├── pages/admin/
│ └── AdminAnalyticsPage.tsx # NOVO — cards, gráfico SVG, tabelas
├── services/
│ └── analytics.ts # NOVO — 3 funções Axios tipadas
└── components/
└── Navbar.tsx # MODIFICADO — link "Analytics" na nav admin
Complexity Tracking
Nenhuma violação da Constituição identificada.
Phase 0: Research
Saída em: research.md
Unknowns Resolvidos
1. Estratégia de rastreamento: before_request vs. after_request
- Decisão:
before_requestcom commit dentro detry/exceptque engole todas as exceções. - Racional: Não bloqueia a resposta ao visitante (FR-009).
before_requesté mais simples — rota e método já estão disponíveis sem inspecionar o response object. - Alternativas descartadas: Celery/queue assíncrona — complexidade desnecessária para o volume esperado (YAGNI, Princípio VI).
2. Anonimização do IP
- Decisão:
hashlib.sha256((ip + salt).encode()).hexdigest()onde salt =app.config["IP_SALT"](variável de ambienteIP_SALT, obrigatória, sem padrão). - Racional: Hash irreversível + salt impede rainbow-table; atende SC-005 e FR-010.
- Alternativas descartadas: Truncar IP (reversível), MD5 (colisões conhecidas), bcrypt (lento demais para before_request).
3. Extração do property_id do caminho
- Decisão: Regex
r"^/api/v1/properties/([0-9a-f-]{36})$"aplicado sobrerequest.pathdentro do hook. - Racional: A rota de detalhe (
/api/v1/properties/<uuid>) é o único endpoint a associarproperty_id. Regex simples sem importar o mapa interno de rotas do Flask. - Alternativas descartadas:
request.view_args(nem sempre disponível em before_request cross-blueprint).
4. Gráfico de linha sem biblioteca externa
- Decisão: SVG inline gerado no componente React com
<polyline>calculado a partir dedaily_series. Eixo X = dias, eixo Y normalizado pela altura do viewBox. - Racional: Sem nova dependência npm (Princípio VI). Recharts/Chart.js adicionariam >200 KB ao bundle sem justificativa para o escopo da feature.
- Alternativas descartadas: Recharts (dependência nova injustificada), bar chart CSS-only (menos informativo para série temporal contínua).
5. Filtro de período no frontend
- Decisão: Estado local
days: 7 | 30 | 90na página; passado como query param?days=Npara os 3 endpoints. - Racional: Spec FR-008 define exatamente 3 períodos fixos; query param simples é suficiente.
Phase 1: Design & Contracts
Saídas em: data-model.md · contracts/analytics-api.md · quickstart.md
Data Model — tabela page_views
| Coluna | Tipo | Nullable | Notas |
|---|---|---|---|
id |
UUID (PK, default gen_random_uuid()) | NO | |
path |
VARCHAR(500) | NO | Caminho da URL, ex: /imoveis/slug |
property_id |
UUID (sem FK constraint) | YES | Preenchido apenas para rotas de detalhe de imóvel |
accessed_at |
TIMESTAMP (server default now()) | NO | Indexado — base de todas as queries por período |
ip_hash |
VARCHAR(64) | NO | SHA-256 hex do (IP + salt) |
user_agent |
VARCHAR(500) | YES | Truncado em 500 chars |
Índices: ix_page_views_accessed_at, ix_page_views_property_id.
Sem FK em property_id: preserva registros históricos mesmo após remoção de imóveis.
API Endpoints
Blueprint: analytics_bp, montado em /api/v1/admin → prefixo efetivo /api/v1/admin/analytics
Todos os endpoints exigem Authorization: Bearer <token> de administrador (require_admin).
GET /api/v1/admin/analytics/summary?days=30
Cards de métricas + série temporal diária.
{
"today": 42,
"this_week": 310,
"this_month": 1204,
"period_total": 1204,
"daily_series": [
{ "date": "2026-03-16", "count": 38 },
{ "date": "2026-03-17", "count": 0 }
]
}
today,this_week,this_month: calculados a partir da data corrente, independente dedays.daily_series: exatamentedaysentradas; dias sem dados têmcount: 0.
GET /api/v1/admin/analytics/top-pages?days=30
{
"items": [
{ "path": "/imoveis/apartamento-centro", "count": 87 }
]
}
Top 10 caminhos por contagem de acessos no período.
GET /api/v1/admin/analytics/top-properties?days=30
{
"items": [
{
"property_id": "uuid",
"title": "Apartamento Centro",
"cover_photo": "/uploads/foto.jpg",
"count": 87
}
]
}
- JOIN com
propertiespara obtertitleecover_photo(primeira foto da relaçãophotos). - LEFT JOIN — se imóvel não existir mais, o item é omitido do resultado.
Before-Request Hook (rastreamento)
Inserido em create_app() após o registro do analytics_bp:
import hashlib, re
from app.models.page_view import PageView
@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()
Pydantic Schemas (backend/app/schemas/analytics.py)
from pydantic import BaseModel
class DailyPoint(BaseModel):
date: str # "YYYY-MM-DD"
count: int
class AnalyticsSummary(BaseModel):
today: int
this_week: int
this_month: int
period_total: int
daily_series: list[DailyPoint]
class TopPageItem(BaseModel):
path: str
count: int
class TopPagesResponse(BaseModel):
items: list[TopPageItem]
class TopPropertyItem(BaseModel):
property_id: str
title: str
cover_photo: str | None
count: int
class TopPropertiesResponse(BaseModel):
items: list[TopPropertyItem]
Alembic Migration (esqueleto)
# upgrade
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"),
)
op.create_index("ix_page_views_accessed_at", "page_views", ["accessed_at"])
op.create_index("ix_page_views_property_id", "page_views", ["property_id"])
# downgrade
op.drop_index("ix_page_views_property_id", table_name="page_views")
op.drop_index("ix_page_views_accessed_at", table_name="page_views")
op.drop_table("page_views")
Frontend — AdminAnalyticsPage.tsx
Rota: /admin/analytics (protegida por AdminRoute, adicionada no router em App.tsx)
Componentes internos (inline na página — sem arquivos separados por simplicidade v1):
PeriodFilter: botões "7d / 30d / 90d", ativo combg-brand text-white; inativo combg-surface text-textSecondaryMetricCard:bg-panel border border-borderSubtle shadow-card rounded-lg; valor emtext-textPrimary text-2xl font-semibold; label emtext-textSecondary text-smLineChart: SVGviewBox="0 0 600 120",<polyline>calculado; linha emstroke="#7170ff"; área de fundo com<polygon>opacity 0.1fill="#5e6ad2"; eixo X com labels de dataTopPagesTableeTopPropertiesTable:bg-panel, linhas alternadas combg-surface,text-textPrimaryetext-textSecondary
Serviço (frontend/src/services/analytics.ts):
import { api } from './api'
export const getAnalyticsSummary = (days: number) =>
api.get<AnalyticsSummary>(`/admin/analytics/summary?days=${days}`)
export const getTopPages = (days: number) =>
api.get<TopPagesResponse>(`/admin/analytics/top-pages?days=${days}`)
export const getTopProperties = (days: number) =>
api.get<TopPropertiesResponse>(`/admin/analytics/top-properties?days=${days}`)
Testes pytest — 9 cenários
| # | Cenário |
|---|---|
| 1 | GET /summary sem token → 401 |
| 2 | GET /summary com token admin → 200, shape correto |
| 3 | GET /summary com 0 registros → campos zerados, daily_series com count: 0 para cada dia |
| 4 | GET /top-pages retorna apenas acessos dentro do período (days) |
| 5 | GET /top-properties LEFT JOIN correto — imóvel inexistente omitido |
| 6 | Hook NOT registra acesso a /api/v1/admin/* |
| 7 | Hook NOT registra acesso a /api/v1/auth/* |
| 8 | Hook registra acesso a rota pública e cria PageView no banco |
| 9 | Falha de banco no hook não propaga exceção para o cliente |
Checklist de Entrega
backend/app/models/page_view.pycriado e importado em__init__.py- Hook
before_requestemcreate_app(), excluindo/api/v1/admin/*,/api/v1/auth/*,/static - Migração Alembic criada e testada (upgrade + downgrade)
- 3 endpoints GET em
backend/app/routes/analytics.py, protegidos porrequire_admin - Pydantic schemas em
backend/app/schemas/analytics.py - Testes em
backend/tests/test_analytics.py(9 cenários) frontend/src/services/analytics.tscom 3 funções tipadasAdminAnalyticsPage.tsxcom cards, gráfico SVG e tabelas usando tokens do design system- Link "Analytics" adicionado no
Navbar.tsx(área admin) - Rota
/admin/analyticsregistrada no router emApp.tsx - Variável de ambiente
IP_SALTdocumentada como obrigatória