# Implementation Plan: Analytics Dashboard (Admin) **Branch**: `016-analytics-dashboard` | **Date**: 2026-04-14 | **Spec**: [spec.md](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](research.md)* ### Unknowns Resolvidos #### 1. Estratégia de rastreamento: before_request vs. after_request - **Decisão**: `before_request` com commit dentro de `try/except` que 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 ambiente `IP_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 sobre `request.path` dentro do hook. - **Racional**: A rota de detalhe (`/api/v1/properties/`) é o único endpoint a associar `property_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 `` calculado a partir de `daily_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 | 90` na página; passado como query param `?days=N` para 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](data-model.md) · [contracts/analytics-api.md](contracts/analytics-api.md) · [quickstart.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 ` de administrador (`require_admin`). #### GET /api/v1/admin/analytics/summary?days=30 Cards de métricas + série temporal diária. ```json { "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 de `days`. - `daily_series`: exatamente `days` entradas; dias sem dados têm `count: 0`. #### GET /api/v1/admin/analytics/top-pages?days=30 ```json { "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 ```json { "items": [ { "property_id": "uuid", "title": "Apartamento Centro", "cover_photo": "/uploads/foto.jpg", "count": 87 } ] } ``` - JOIN com `properties` para obter `title` e `cover_photo` (primeira foto da relação `photos`). - 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`: ```python 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`) ```python 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) ```python # 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 com `bg-brand text-white`; inativo com `bg-surface text-textSecondary` - `MetricCard`: `bg-panel border border-borderSubtle shadow-card rounded-lg`; valor em `text-textPrimary text-2xl font-semibold`; label em `text-textSecondary text-sm` - `LineChart`: SVG `viewBox="0 0 600 120"`, `` calculado; linha em `stroke="#7170ff"`; área de fundo com `` opacity 0.1 `fill="#5e6ad2"`; eixo X com labels de data - `TopPagesTable` e `TopPropertiesTable`: `bg-panel`, linhas alternadas com `bg-surface`, `text-textPrimary` e `text-textSecondary` **Serviço (`frontend/src/services/analytics.ts`)**: ```typescript import { api } from './api' export const getAnalyticsSummary = (days: number) => api.get(`/admin/analytics/summary?days=${days}`) export const getTopPages = (days: number) => api.get(`/admin/analytics/top-pages?days=${days}`) export const getTopProperties = (days: number) => api.get(`/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.py` criado e importado em `__init__.py` - [ ] Hook `before_request` em `create_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 por `require_admin` - [ ] Pydantic schemas em `backend/app/schemas/analytics.py` - [ ] Testes em `backend/tests/test_analytics.py` (9 cenários) - [ ] `frontend/src/services/analytics.ts` com 3 funções tipadas - [ ] `AdminAnalyticsPage.tsx` com cards, gráfico SVG e tabelas usando tokens do design system - [ ] Link "Analytics" adicionado no `Navbar.tsx` (área admin) - [ ] Rota `/admin/analytics` registrada no router em `App.tsx` - [ ] Variável de ambiente `IP_SALT` documentada como obrigatória