feat: Implement map analytics system with effectiveness tracking
Add complete map analytics pipeline to answer: "Which maps drive the most players? Is my rotation working?" Backend Changes: - Migration 005: Add map_id FK to server_stats and wipe_history tables - Stats consumer now captures current_map_id when persisting stats - Map analytics queries: get_map_analytics() returns performance metrics, effectiveness scores, and rotation health - API endpoint: GET /api/analytics/maps?range=90d returns summary with best performing map and rotation effectiveness percentage Frontend Changes: - MapAnalyticsView.vue: Complete dashboard with performance charts, sortable metrics table, actionable insights, and CSV export - ECharts bar chart comparing avg vs peak players per map - Color-coded effectiveness scoring (green ≥80%, yellow ≥60%, red <60%) - Time range selector: 30d/90d/all Purpose: Enables data-driven map selection for wipe day based on player engagement metrics. Rotation effectiveness algorithm scores maps by (avg_players / peak_players) * 100. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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<Arc<AppState>> {
|
||||
.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<wipes::WipeAnalyticsEntry>,
|
||||
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<Arc<AppState>>,
|
||||
Query(query): Query<WipeAnalyticsQuery>,
|
||||
) -> ApiResult<Json<WipePerformanceResponse>> {
|
||||
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::<i64>().unwrap_or(90)
|
||||
} else {
|
||||
90 // Default to 90 days
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user