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:
Vantz Stockwell
2026-02-15 14:22:55 -05:00
parent dc7f41b8c5
commit cef89ade18
9 changed files with 691 additions and 4 deletions

View File

@@ -0,0 +1,318 @@
<script setup lang="ts">
import { ref, onMounted, watch, nextTick, computed } from 'vue'
import { Map, TrendingUp, Award, Target, Download } from 'lucide-vue-next'
import * as echarts from 'echarts'
import type { ECharts } from 'echarts'
import { useApi } from '@/composables/useApi'
import type { MapAnalyticsSummary } from '@/types'
const api = useApi()
const timeRange = ref<'30d' | '90d' | 'all'>('90d')
const loading = ref(true)
const analytics = ref<MapAnalyticsSummary | null>(null)
const performanceChart = ref<HTMLElement | null>(null)
let performanceChartInstance: ECharts | null = null
const loadMapAnalytics = async () => {
loading.value = true
try {
const response = await api.get<MapAnalyticsSummary>(`/api/analytics/maps?range=${timeRange.value}`)
analytics.value = response
await nextTick()
renderCharts()
} catch (error) {
console.error('Failed to load map analytics:', error)
} finally {
loading.value = false
}
}
const renderCharts = () => {
if (!analytics.value || analytics.value.maps.length === 0) return
// Map performance bar chart (avg players per map)
if (performanceChart.value) {
if (performanceChartInstance) {
performanceChartInstance.dispose()
}
performanceChartInstance = echarts.init(performanceChart.value)
const mapNames = analytics.value.maps.map(m => m.map_name)
const avgPlayers = analytics.value.maps.map(m => m.avg_players)
const peakPlayers = analytics.value.maps.map(m => m.peak_players)
performanceChartInstance.setOption({
backgroundColor: 'transparent',
tooltip: {
trigger: 'axis',
backgroundColor: '#1a1a1a',
borderColor: '#2a2a2a',
textStyle: { color: '#e5e5e5' },
axisPointer: {
type: 'shadow'
}
},
legend: {
data: ['Avg Players', 'Peak Players'],
textStyle: { color: '#a3a3a3' },
top: 0
},
grid: {
left: '3%',
right: '4%',
bottom: '10%',
top: '15%',
containLabel: true
},
xAxis: {
type: 'category',
data: mapNames,
axisLine: { lineStyle: { color: '#404040' } },
axisLabel: { color: '#808080', rotate: 45 }
},
yAxis: {
type: 'value',
name: 'Players',
axisLine: { lineStyle: { color: '#404040' } },
splitLine: { lineStyle: { color: '#2a2a2a' } },
axisLabel: { color: '#808080' }
},
series: [
{
name: 'Avg Players',
type: 'bar',
data: avgPlayers,
itemStyle: { color: '#CE422B' },
barGap: '10%'
},
{
name: 'Peak Players',
type: 'bar',
data: peakPlayers,
itemStyle: { color: '#10b981' },
barGap: '10%'
}
]
})
}
}
const sortedMaps = computed(() => {
if (!analytics.value) return []
return [...analytics.value.maps].sort((a, b) => b.avg_players - a.avg_players)
})
const downloadCSV = () => {
if (!analytics.value) return
const headers = ['Map Name', 'Seed', 'Times Used', 'Avg Players', 'Peak Players', 'Effectiveness Score (%)']
const rows = analytics.value.maps.map(m => [
m.map_name,
m.seed ?? 'N/A',
m.times_used,
m.avg_players.toFixed(1),
m.peak_players,
m.effectiveness_score.toFixed(1)
])
const csv = [headers.join(','), ...rows.map(r => r.join(','))].join('\n')
const blob = new Blob([csv], { type: 'text/csv' })
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `map_analytics_${timeRange.value}.csv`
a.click()
window.URL.revokeObjectURL(url)
}
watch(timeRange, () => {
loadMapAnalytics()
})
onMounted(() => {
loadMapAnalytics()
})
</script>
<template>
<div class="p-6 space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<Map class="w-5 h-5 text-oxide-500" />
<h1 class="text-2xl font-bold text-neutral-100">Map Analytics</h1>
</div>
<div class="flex items-center gap-3">
<button
@click="downloadCSV"
class="flex items-center gap-2 px-3 py-2 bg-neutral-800 border border-neutral-700 rounded-lg hover:bg-neutral-700 transition-colors text-sm text-neutral-300"
>
<Download class="w-4 h-4" />
Export CSV
</button>
<div class="flex bg-neutral-800 rounded-lg border border-neutral-700 overflow-hidden">
<button
v-for="opt in (['30d', '90d', 'all'] as const)"
:key="opt"
@click="timeRange = opt"
class="px-3 py-2 text-sm font-medium transition-colors"
:class="timeRange === opt ? 'bg-oxide-500/15 text-oxide-400' : 'text-neutral-400 hover:text-neutral-200'"
>
{{ opt }}
</button>
</div>
</div>
</div>
<!-- Loading state -->
<div v-if="loading" class="flex items-center justify-center py-12">
<div class="text-neutral-500">Loading map analytics...</div>
</div>
<template v-else-if="analytics">
<!-- Summary cards -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
<div class="flex items-center gap-2 mb-2">
<Award class="w-4 h-4 text-oxide-400" />
<p class="text-sm text-neutral-400">Best Performing Map</p>
</div>
<p class="text-xl font-bold text-neutral-100">
{{ analytics.best_performing_map ?? 'No data' }}
</p>
<p class="text-xs text-neutral-600 mt-1" v-if="analytics.maps.length > 0">
Avg {{ analytics.maps[0]?.avg_players.toFixed(1) }} players
</p>
</div>
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
<div class="flex items-center gap-2 mb-2">
<Target class="w-4 h-4 text-green-400" />
<p class="text-sm text-neutral-400">Rotation Effectiveness</p>
</div>
<p class="text-xl font-bold text-neutral-100">
{{ analytics.rotation_effectiveness.toFixed(1) }}%
</p>
<p class="text-xs text-neutral-600 mt-1">Overall rotation health</p>
</div>
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
<div class="flex items-center gap-2 mb-2">
<TrendingUp class="w-4 h-4 text-blue-400" />
<p class="text-sm text-neutral-400">Total Maps Tracked</p>
</div>
<p class="text-xl font-bold text-neutral-100">{{ analytics.maps.length }}</p>
<p class="text-xs text-neutral-600 mt-1">Last {{ timeRange }}</p>
</div>
</div>
<!-- Performance chart -->
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider mb-4">
Map Performance Comparison
</h2>
<div v-if="analytics.maps.length > 0" ref="performanceChart" class="h-80"></div>
<div v-else class="h-80 flex items-center justify-center border border-dashed border-neutral-800 rounded-lg">
<p class="text-sm text-neutral-600">No map data available for this time range</p>
</div>
</div>
<!-- Map performance table -->
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider mb-4">
Detailed Map Metrics
</h2>
<div v-if="sortedMaps.length > 0" class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="border-b border-neutral-800">
<tr>
<th class="text-left py-3 px-4 text-neutral-400 font-medium">Map Name</th>
<th class="text-left py-3 px-4 text-neutral-400 font-medium">Seed</th>
<th class="text-right py-3 px-4 text-neutral-400 font-medium">Times Used</th>
<th class="text-right py-3 px-4 text-neutral-400 font-medium">Avg Players</th>
<th class="text-right py-3 px-4 text-neutral-400 font-medium">Peak Players</th>
<th class="text-right py-3 px-4 text-neutral-400 font-medium">Effectiveness</th>
</tr>
</thead>
<tbody>
<tr
v-for="map in sortedMaps"
:key="map.map_id"
class="border-b border-neutral-800/50 hover:bg-neutral-800/30 transition-colors"
>
<td class="py-3 px-4 text-neutral-200 font-medium">{{ map.map_name }}</td>
<td class="py-3 px-4 text-neutral-400">{{ map.seed ?? '—' }}</td>
<td class="py-3 px-4 text-right text-neutral-300">{{ map.times_used }}</td>
<td class="py-3 px-4 text-right text-neutral-300">{{ map.avg_players.toFixed(1) }}</td>
<td class="py-3 px-4 text-right text-neutral-300">{{ map.peak_players }}</td>
<td class="py-3 px-4 text-right">
<span
class="inline-flex items-center px-2 py-1 rounded text-xs font-medium"
:class="{
'bg-green-500/10 text-green-400': map.effectiveness_score >= 80,
'bg-yellow-500/10 text-yellow-400': map.effectiveness_score >= 60 && map.effectiveness_score < 80,
'bg-red-500/10 text-red-400': map.effectiveness_score < 60
}"
>
{{ map.effectiveness_score.toFixed(1) }}%
</span>
</td>
</tr>
</tbody>
</table>
</div>
<div v-else class="py-8 flex items-center justify-center border border-dashed border-neutral-800 rounded-lg">
<p class="text-sm text-neutral-600">No map data available</p>
</div>
</div>
<!-- Insights section -->
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
<h2 class="text-sm font-medium text-neutral-400 uppercase tracking-wider mb-4">
Actionable Insights
</h2>
<div class="space-y-3">
<div v-if="analytics.best_performing_map" class="flex items-start gap-3 p-3 bg-neutral-800/50 rounded-lg">
<Award class="w-5 h-5 text-oxide-400 mt-0.5 flex-shrink-0" />
<div>
<p class="text-sm text-neutral-200 font-medium">
Your best map is <span class="text-oxide-400">{{ analytics.best_performing_map }}</span>
</p>
<p class="text-xs text-neutral-500 mt-1">
Consider featuring this map more frequently in your rotation for maximum player engagement.
</p>
</div>
</div>
<div
v-if="analytics.rotation_effectiveness < 70"
class="flex items-start gap-3 p-3 bg-yellow-500/5 border border-yellow-500/20 rounded-lg"
>
<Target class="w-5 h-5 text-yellow-400 mt-0.5 flex-shrink-0" />
<div>
<p class="text-sm text-neutral-200 font-medium">Rotation effectiveness is below optimal</p>
<p class="text-xs text-neutral-500 mt-1">
Consider removing low-performing maps (effectiveness &lt; 60%) and testing new maps to improve overall rotation health.
</p>
</div>
</div>
<div
v-else-if="analytics.rotation_effectiveness >= 80"
class="flex items-start gap-3 p-3 bg-green-500/5 border border-green-500/20 rounded-lg"
>
<TrendingUp class="w-5 h-5 text-green-400 mt-0.5 flex-shrink-0" />
<div>
<p class="text-sm text-neutral-200 font-medium">Excellent rotation effectiveness!</p>
<p class="text-xs text-neutral-500 mt-1">
Your current map rotation is driving strong player engagement. Keep monitoring for any changes.
</p>
</div>
</div>
</div>
</div>
</template>
</div>
</template>