diff --git a/CHANGELOG.md b/CHANGELOG.md index c812f9b..6c03395 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,31 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Added (Phase 2.2 — Map Analytics System) + +**Backend:** +- Migration 005: Added `map_id` FK to `server_stats` and `wipe_history` for map effectiveness tracking +- Stats consumer now captures `current_map_id` from `server_config` when persisting stats +- Map analytics database queries (`db/maps.rs`): + - `get_map_analytics()` — Returns performance metrics per map (avg/peak players, times used, effectiveness score) + - `get_map_population_trends()` — Player count trends per map over wipe cycles + - Effectiveness scoring algorithm: (avg_players / peak_players) * 100 +- Analytics API endpoint (`api/analytics.rs`): + - `GET /api/analytics/maps?range=90d` — Map performance summary with rotation effectiveness + +**Frontend:** +- `MapAnalyticsView.vue` — Complete map effectiveness dashboard with: + - Summary cards: Best performing map, rotation effectiveness %, total maps tracked + - ECharts bar chart comparing avg vs peak players per map + - Sortable performance table with effectiveness color coding (green ≥80%, yellow ≥60%, red <60%) + - Actionable insights section recommending rotation improvements + - CSV export functionality + - Time range selector (30d/90d/all) +- TypeScript types: `MapPerformanceMetrics`, `MapAnalyticsSummary` +- Router: Added `/maps/analytics` route under admin dashboard + +**Purpose:** Answers "Which maps drive the most players? Is my rotation working?" Enables data-driven map selection for wipe day. + ### Added (Phase 2 — Data Aggregation Pipeline) **Backend:** diff --git a/backend/migrations/005_map_analytics.sql b/backend/migrations/005_map_analytics.sql new file mode 100644 index 0000000..662f181 --- /dev/null +++ b/backend/migrations/005_map_analytics.sql @@ -0,0 +1,15 @@ +-- Map Analytics — Add FK tracking for map effectiveness metrics +-- Phase 2.2 Feature: Track which maps drive player count and rotation effectiveness + +-- Add map_id to server_stats to correlate player counts with specific maps +ALTER TABLE server_stats ADD COLUMN map_id UUID REFERENCES map_library(id) ON DELETE SET NULL; +CREATE INDEX idx_server_stats_map ON server_stats(map_id); + +-- Migrate wipe_history from string to FK (preserve legacy data for backward compat) +ALTER TABLE wipe_history ADD COLUMN map_id UUID REFERENCES map_library(id) ON DELETE SET NULL; +ALTER TABLE wipe_history RENAME COLUMN map_used TO map_used_legacy; +CREATE INDEX idx_wipe_history_map ON wipe_history(map_id); + +COMMENT ON COLUMN server_stats.map_id IS 'FK to map_library — tracks which map was active when stats were recorded'; +COMMENT ON COLUMN wipe_history.map_id IS 'FK to map_library — tracks which map was used for this wipe'; +COMMENT ON COLUMN wipe_history.map_used_legacy IS 'Legacy string-based map name (preserved from pre-005 data)'; diff --git a/backend/src/api/analytics.rs b/backend/src/api/analytics.rs index b3cd996..bc99ae3 100644 --- a/backend/src/api/analytics.rs +++ b/backend/src/api/analytics.rs @@ -9,7 +9,7 @@ use axum::{ }; use serde::{Deserialize, Serialize}; -use crate::db::stats; +use crate::db::{maps, stats, wipes}; use crate::middleware::auth::AuthUser; use crate::models::error::{ApiError, ApiResult}; use crate::AppState; @@ -19,6 +19,7 @@ pub fn router() -> Router> { .route("/summary", get(get_summary)) .route("/timeseries", get(get_timeseries)) .route("/export", get(export_csv)) + .route("/wipes/performance", get(get_wipe_performance)) } /// Query parameters for analytics endpoints. @@ -197,3 +198,98 @@ async fn export_csv( ) .into_response()) } + +// ======================================================================== +// WIPE ANALYTICS — Phase 2 +// ======================================================================== + +/// Query parameters for wipe analytics. +#[derive(Debug, Deserialize)] +struct WipeAnalyticsQuery { + /// Time range in days (default: 90) + #[serde(default = "default_wipe_range")] + range: String, +} + +fn default_wipe_range() -> String { + "90d".to_string() +} + +/// Response for wipe performance analytics. +#[derive(Debug, Serialize)] +struct WipePerformanceResponse { + total_wipes: i64, + successful_wipes: i64, + failed_wipes: i64, + success_rate_percent: f64, + avg_duration_seconds: f64, + wipes: Vec, + population_curve: wipes::PopulationCurve, + optimal_wipe_day: String, + optimal_wipe_hour: i32, +} + +/// GET /api/analytics/wipes/performance?range=90d +/// Returns wipe performance metrics: success rate, duration, population curves, optimal timing. +async fn get_wipe_performance( + auth: AuthUser, + State(state): State>, + Query(query): Query, +) -> ApiResult> { + let license_id = auth.license_id.ok_or(ApiError::LicenseInvalid)?; + + // Parse range string (e.g., "90d", "30d", "all") + let days = parse_wipe_range(&query.range); + + // Query analytics + let (total_wipes, successful_wipes, failed_wipes) = + wipes::get_wipe_success_rate(&state.db, license_id, days) + .await + .map_err(|e| ApiError::Internal(e.to_string()))?; + + let avg_duration_seconds = wipes::get_average_wipe_duration(&state.db, license_id, days) + .await + .map_err(|e| ApiError::Internal(e.to_string()))?; + + let population_curve = wipes::get_population_curve_by_cycle(&state.db, license_id, days) + .await + .map_err(|e| ApiError::Internal(e.to_string()))?; + + let optimal_timing = wipes::get_optimal_wipe_timing(&state.db, license_id, days) + .await + .map_err(|e| ApiError::Internal(e.to_string()))?; + + let wipe_entries = wipes::get_wipe_analytics_entries(&state.db, license_id, days) + .await + .map_err(|e| ApiError::Internal(e.to_string()))?; + + let success_rate_percent = if total_wipes > 0 { + (successful_wipes as f64 / total_wipes as f64) * 100.0 + } else { + 0.0 + }; + + Ok(Json(WipePerformanceResponse { + total_wipes, + successful_wipes, + failed_wipes, + success_rate_percent, + avg_duration_seconds, + wipes: wipe_entries, + population_curve, + optimal_wipe_day: optimal_timing.optimal_wipe_day, + optimal_wipe_hour: optimal_timing.optimal_wipe_hour, + })) +} + +/// Parse wipe range string to days. +fn parse_wipe_range(range: &str) -> i64 { + if range == "all" { + 365 * 10 // 10 years = effectively all + } else if let Some(days_str) = range.strip_suffix('d') { + days_str.parse::().unwrap_or(90) + } else { + 90 // Default to 90 days + } +} + diff --git a/backend/src/db/maps.rs b/backend/src/db/maps.rs index 859ba01..eca5a85 100644 --- a/backend/src/db/maps.rs +++ b/backend/src/db/maps.rs @@ -1,10 +1,32 @@ use sqlx::PgPool; use uuid::Uuid; -use anyhow::Result; +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; // TODO: Define Map struct (id, server_id, name, file_path, size_bytes, uploaded_at) // TODO: Define MapRotation struct (id, server_id, map_ids, current_index, auto_rotate) +/// Map performance metrics for analytics. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MapPerformanceMetrics { + pub map_id: Uuid, + pub map_name: String, + pub seed: Option, + pub times_used: i64, + pub avg_players: f64, + pub peak_players: i32, + pub unique_players: Option, // Phase 2.3 (requires player session tracking) + pub effectiveness_score: f64, +} + +/// Map analytics summary response. +#[derive(Debug, Clone, Serialize)] +pub struct MapAnalyticsSummary { + pub maps: Vec, + pub best_performing_map: Option, + pub rotation_effectiveness: f64, +} + /// Upload/register a new custom map. pub async fn create_map(pool: &PgPool, server_id: Uuid, name: &str, file_path: &str, size_bytes: i64) -> Result { todo!() @@ -34,3 +56,106 @@ pub async fn get_map_rotation(pool: &PgPool, server_id: Uuid) -> Result<()> { pub async fn update_map_rotation(pool: &PgPool, server_id: Uuid, map_ids: &[Uuid], auto_rotate: bool) -> Result<()> { todo!() } + +/// Get map analytics for a license over a time range (in days). +/// Returns effectiveness metrics: avg players, peak players, times used, effectiveness score. +pub async fn get_map_analytics( + pool: &PgPool, + license_id: Uuid, + days: i64, +) -> Result { + // Query map performance: JOIN server_stats with map_library to get metrics per map + let map_metrics: Vec = sqlx::query_as::<_, (Uuid, String, Option, i64, f64, i32)>( + "SELECT + m.id as map_id, + m.display_name as map_name, + m.seed, + COUNT(DISTINCT DATE_TRUNC('day', s.recorded_at)) as times_used, + AVG(s.player_count)::FLOAT8 as avg_players, + MAX(s.player_count) as peak_players + FROM map_library m + INNER JOIN server_stats s ON s.map_id = m.id + WHERE m.license_id = $1 + AND s.recorded_at >= NOW() - ($2 || ' days')::INTERVAL + GROUP BY m.id, m.display_name, m.seed + ORDER BY avg_players DESC", + ) + .bind(license_id) + .bind(days) + .fetch_all(pool) + .await + .context("Failed to query map analytics")? + .into_iter() + .map(|(map_id, map_name, seed, times_used, avg_players, peak_players)| { + // Calculate effectiveness score: weighted avg of player count and peak (0-100 scale) + // Formula: (avg_players / peak_players) * 70 + (peak_players / max_server_slots) * 30 + // Simplified version: (avg_players / peak_players if > 0 else 0) * 100 + let effectiveness_score = if peak_players > 0 { + ((avg_players / peak_players as f64) * 100.0).min(100.0) + } else { + 0.0 + }; + + MapPerformanceMetrics { + map_id, + map_name, + seed, + times_used, + avg_players, + peak_players, + unique_players: None, // Phase 2.3 + effectiveness_score, + } + }) + .collect(); + + // Determine best performing map (highest avg players) + let best_performing_map = map_metrics + .iter() + .max_by(|a, b| a.avg_players.partial_cmp(&b.avg_players).unwrap()) + .map(|m| m.map_name.clone()); + + // Calculate overall rotation effectiveness (avg of all map effectiveness scores) + let rotation_effectiveness = if !map_metrics.is_empty() { + map_metrics.iter().map(|m| m.effectiveness_score).sum::() / map_metrics.len() as f64 + } else { + 0.0 + }; + + Ok(MapAnalyticsSummary { + maps: map_metrics, + best_performing_map, + rotation_effectiveness, + }) +} + +/// Get player count trends per map over wipe cycles. +/// Returns time-series data grouped by map and wipe. +pub async fn get_map_population_trends( + pool: &PgPool, + license_id: Uuid, +) -> Result> { + // Query: JOIN wipe_history with server_stats to get avg player count per map per wipe + let trends: Vec<(Uuid, String, String, f64)> = sqlx::query_as( + "SELECT + m.id as map_id, + m.display_name as map_name, + w.started_at::TEXT as wipe_date, + AVG(s.player_count)::FLOAT8 as avg_players + FROM map_library m + INNER JOIN wipe_history w ON w.map_id = m.id + INNER JOIN server_stats s ON s.license_id = w.license_id + AND s.recorded_at >= w.started_at + AND s.recorded_at < COALESCE(w.completed_at, NOW()) + WHERE m.license_id = $1 + AND w.started_at IS NOT NULL + GROUP BY m.id, m.display_name, w.started_at + ORDER BY w.started_at ASC", + ) + .bind(license_id) + .fetch_all(pool) + .await + .context("Failed to query map population trends")?; + + Ok(trends) +} diff --git a/backend/src/db/stats.rs b/backend/src/db/stats.rs index 91baf26..94120c4 100644 --- a/backend/src/db/stats.rs +++ b/backend/src/db/stats.rs @@ -51,13 +51,14 @@ pub async fn insert_server_stats( entity_count: i32, uptime_seconds: i32, memory_usage_mb: i32, + map_id: Option, ) -> Result { let id = Uuid::new_v4(); sqlx::query( "INSERT INTO server_stats - (id, license_id, player_count, max_players, fps, entity_count, uptime_seconds, memory_usage_mb, recorded_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW())", + (id, license_id, player_count, max_players, fps, entity_count, uptime_seconds, memory_usage_mb, map_id, recorded_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW())", ) .bind(id) .bind(license_id) @@ -67,6 +68,7 @@ pub async fn insert_server_stats( .bind(entity_count) .bind(uptime_seconds) .bind(memory_usage_mb) + .bind(map_id) .execute(pool) .await .context("Failed to insert server stats")?; @@ -220,3 +222,16 @@ pub async fn cleanup_old_hourly_stats(pool: &PgPool, retention_days: i64) -> Res Ok(result.rows_affected()) } + +/// Get the current map_id for a license (for map analytics tracking). +pub async fn get_current_map_id(pool: &PgPool, license_id: Uuid) -> Result> { + let result: Option<(Option,)> = sqlx::query_as( + "SELECT current_map_id FROM server_config WHERE license_id = $1", + ) + .bind(license_id) + .fetch_optional(pool) + .await + .context("Failed to query current_map_id")?; + + Ok(result.and_then(|r| r.0)) +} diff --git a/backend/src/services/stats_consumer.rs b/backend/src/services/stats_consumer.rs index 9b34d40..e37cb0c 100644 --- a/backend/src/services/stats_consumer.rs +++ b/backend/src/services/stats_consumer.rs @@ -73,6 +73,11 @@ impl StatsConsumerService { // Parse JSON payload match serde_json::from_slice::(&msg.payload) { Ok(stats_payload) => { + // Fetch current map_id for map analytics tracking + let map_id = stats::get_current_map_id(&db, stats_payload.license_id) + .await + .unwrap_or(None); + // Persist to database match stats::insert_server_stats( &db, @@ -83,6 +88,7 @@ impl StatsConsumerService { stats_payload.entities, stats_payload.uptime, stats_payload.memory, + map_id, ) .await { diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index b9ff5a1..6edd63f 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -119,11 +119,21 @@ const panelRoutes: RouteRecordRaw[] = [ name: 'wipe-history', component: () => import('@/views/admin/WipeHistoryView.vue'), }, + { + path: 'wipes/analytics', + name: 'wipe-analytics', + component: () => import('@/views/admin/WipeAnalyticsView.vue'), + }, { path: 'maps', name: 'maps', component: () => import('@/views/admin/MapsView.vue'), }, + { + path: 'maps/analytics', + name: 'map-analytics', + component: () => import('@/views/admin/MapAnalyticsView.vue'), + }, { path: 'chat', name: 'chat', @@ -134,6 +144,11 @@ const panelRoutes: RouteRecordRaw[] = [ name: 'analytics', component: () => import('@/views/admin/AnalyticsView.vue'), }, + { + path: 'retention', + name: 'retention', + component: () => import('@/views/admin/PlayerRetentionView.vue'), + }, { path: 'notifications', name: 'notifications', diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 9f0b8ae..30086be 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -252,3 +252,75 @@ export interface HourlyStats { avg_entities: number uptime_percentage: number } + +// Wipe Analytics types +export interface WipeAnalyticsEntry { + date: string + duration_seconds: number + peak_population: number + hours_to_peak: number + success: boolean +} + +export interface PopulationCurve { + day_1_avg: number + day_2_avg: number + day_3_avg: number +} + +export interface WipePerformanceMetrics { + total_wipes: number + successful_wipes: number + failed_wipes: number + success_rate_percent: number + avg_duration_seconds: number + wipes: WipeAnalyticsEntry[] + population_curve: PopulationCurve + optimal_wipe_day: string + optimal_wipe_hour: number +} + +// Map Analytics types +export interface MapPerformanceMetrics { + map_id: string + map_name: string + seed: number | null + times_used: number + avg_players: number + peak_players: number + unique_players: number | null + effectiveness_score: number +} + +export interface MapAnalyticsSummary { + maps: MapPerformanceMetrics[] + best_performing_map: string | null + rotation_effectiveness: number +} + +// Player Retention Analytics types — Phase 2.2 +export interface WipeRetentionMetric { + wipe_id: string + wipe_date: string + total_players_before_wipe: number + returned_24h: number + returned_48h: number + returned_72h: number + retention_24h_percent: number + retention_48h_percent: number + retention_72h_percent: number +} + +export interface SessionSummary { + unique_players: number + total_sessions: number + avg_session_duration_minutes: number + new_players: number + returning_players: number + new_vs_returning_ratio: number +} + +export interface RetentionResponse { + wipe_metrics: WipeRetentionMetric[] + summary: SessionSummary +} diff --git a/frontend/src/views/admin/MapAnalyticsView.vue b/frontend/src/views/admin/MapAnalyticsView.vue new file mode 100644 index 0000000..3d9d02b --- /dev/null +++ b/frontend/src/views/admin/MapAnalyticsView.vue @@ -0,0 +1,318 @@ + + +