Files
corrosion-admin-panel/frontend/src/views/admin/PlayerRetentionView.vue
Vantz Stockwell b42a2d7ea7
All checks were successful
Test Asgard Runner / test (push) Successful in 3s
feat(redesign): re-skin server-ops/operations/store/analytics views to DS (Phase D batch 2)
18 admin views re-skinned onto design-system components + tokens: Server/Players/Plugins/ChatLog, Wipes/WipeProfiles/Maps/Schedules/Alerts, StoreConfig/StoreItems/ModuleStore, Analytics/WipeAnalytics/MapAnalytics/PlayerRetention/StoreRevenue. ECharts now read var(--accent) (token-driven, follows game skin). 14 icons added to the registry. All logic/store/router/handlers/API calls preserved; presentation-only re-skin. Build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 02:34:46 -04:00

464 lines
13 KiB
Vue

<script setup lang="ts">
import { ref, onMounted, watch, nextTick } from 'vue'
import * as echarts from 'echarts'
import type { ECharts } from 'echarts'
import { useApi } from '@/composables/useApi'
import { useAuthStore } from '@/stores/auth'
import { safeFixed } from '@/utils/formatters'
import Panel from '@/components/ds/data/Panel.vue'
import StatCard from '@/components/ds/data/StatCard.vue'
import Button from '@/components/ds/core/Button.vue'
import EmptyState from '@/components/ds/feedback/EmptyState.vue'
import Select from '@/components/ds/forms/Select.vue'
const api = useApi()
const authStore = useAuthStore()
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
}
interface SessionSummary {
unique_players: number
total_sessions: number
avg_session_duration_minutes: number
new_players: number
returning_players: number
new_vs_returning_ratio: number
}
interface RetentionResponse {
wipe_metrics: WipeRetentionMetric[]
summary: SessionSummary
}
const wipeCount = ref<number>(6)
const loading = ref(true)
const retentionData = ref<RetentionResponse | null>(null)
const retentionChart = ref<HTMLElement | null>(null)
let retentionChartInstance: ECharts | null = null
const loadRetentionData = async () => {
loading.value = true
try {
const response = await api.get<RetentionResponse>(
`/analytics/retention?wipe_count=${wipeCount.value}`
)
retentionData.value = response
await nextTick()
renderCharts()
} catch (error) {
console.error('Failed to load retention data:', error)
} finally {
loading.value = false
}
}
function cssVar(name: string): string {
return getComputedStyle(document.documentElement).getPropertyValue(name).trim()
}
const renderCharts = () => {
if (!retentionData.value || !retentionData.value.wipe_metrics.length) return
// Retention curve chart (24h/48h/72h over multiple wipes)
if (retentionChart.value) {
if (retentionChartInstance) {
retentionChartInstance.dispose()
}
retentionChartInstance = echarts.init(retentionChart.value)
const wipeLabels = retentionData.value.wipe_metrics.map((w) =>
new Date(w.wipe_date).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric'
})
)
const retention24h = retentionData.value.wipe_metrics.map((w) => w.retention_24h_percent)
const retention48h = retentionData.value.wipe_metrics.map((w) => w.retention_48h_percent)
const retention72h = retentionData.value.wipe_metrics.map((w) => w.retention_72h_percent)
const accent = cssVar('--accent') || '#CE422B'
const grid = cssVar('--border-subtle') || 'rgba(255,255,255,0.06)'
const axisLine = cssVar('--border-default') || '#404040'
const labelColor = cssVar('--text-tertiary') || '#808080'
const tooltipBg = cssVar('--surface-overlay') || '#1a1a1a'
const tooltipBorder = cssVar('--border-default') || '#2a2a2a'
const tooltipText = cssVar('--text-primary') || '#e5e5e5'
const mono = 'JetBrains Mono, monospace'
retentionChartInstance.setOption({
backgroundColor: 'transparent',
tooltip: {
trigger: 'axis',
backgroundColor: tooltipBg,
borderColor: tooltipBorder,
textStyle: { color: tooltipText, fontFamily: mono, fontSize: 11 },
formatter: (params: any) => {
let tooltip = `<strong>${params[0].axisValue}</strong><br/>`
params.forEach((param: any) => {
tooltip += `${param.marker} ${param.seriesName}: ${safeFixed(param.value, 1)}%<br/>`
})
return tooltip
}
},
legend: {
data: ['24h Return', '48h Return', '72h Return'],
textStyle: { color: labelColor, fontFamily: mono },
top: 0
},
grid: {
left: '3%',
right: '4%',
bottom: '10%',
top: '15%',
containLabel: true
},
xAxis: {
type: 'category',
data: wipeLabels,
axisLine: { lineStyle: { color: axisLine } },
axisLabel: { color: labelColor, rotate: 45, fontFamily: mono, fontSize: 10 }
},
yAxis: {
type: 'value',
name: 'Retention %',
axisLine: { lineStyle: { color: axisLine } },
splitLine: { lineStyle: { color: grid } },
axisLabel: {
color: labelColor,
fontFamily: mono,
fontSize: 10,
formatter: (value: number) => `${value}%`
},
max: 100
},
series: [
{
name: '24h Return',
type: 'line',
data: retention24h,
smooth: true,
lineStyle: { color: accent, width: 3 },
itemStyle: { color: accent },
symbolSize: 8
},
{
name: '48h Return',
type: 'line',
data: retention48h,
smooth: true,
lineStyle: { color: '#f59e0b', width: 3 },
itemStyle: { color: '#f59e0b' },
symbolSize: 8
},
{
name: '72h Return',
type: 'line',
data: retention72h,
smooth: true,
lineStyle: { color: '#10b981', width: 3 },
itemStyle: { color: '#10b981' },
symbolSize: 8
}
]
})
}
}
const downloadCSV = async () => {
try {
const response = await fetch(`/api/analytics/retention/export?wipe_count=${wipeCount.value}`, {
headers: {
Authorization: `Bearer ${authStore.accessToken}`
}
})
const blob = await response.blob()
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `retention_metrics_${wipeCount.value}_wipes.csv`
a.click()
window.URL.revokeObjectURL(url)
} catch (error) {
console.error('Failed to download CSV:', error)
}
}
watch(wipeCount, () => {
loadRetentionData()
})
onMounted(() => {
loadRetentionData()
})
</script>
<template>
<div class="retention-view">
<!-- Header -->
<div class="retention-view__header">
<h1 class="retention-view__title">Player retention</h1>
<div class="retention-view__controls">
<Button variant="secondary" size="sm" icon="download" @click="downloadCSV">
Export CSV
</Button>
<Select
:options="[
{ value: '3', label: 'Last 3 wipes' },
{ value: '6', label: 'Last 6 wipes' },
{ value: '10', label: 'Last 10 wipes' },
{ value: '20', label: 'Last 20 wipes' }
]"
:model-value="String(wipeCount)"
size="sm"
@update:model-value="wipeCount = Number($event)"
/>
</div>
</div>
<!-- Loading state -->
<div v-if="loading" class="retention-view__loading">
<span class="retention-view__loading-text">Loading retention data...</span>
</div>
<template v-else-if="retentionData && retentionData.wipe_metrics.length > 0">
<!-- Summary cards -->
<div class="retention-view__stats">
<StatCard
label="Unique players"
:value="retentionData.summary.unique_players"
icon="users"
note="Last 30 days"
/>
<StatCard
label="Avg session"
:value="safeFixed(retentionData?.summary?.avg_session_duration_minutes, 0)"
unit="m"
icon="clock"
note="Duration"
/>
<StatCard
label="New players"
:value="retentionData.summary.new_players"
icon="trending-up"
note="Last 30 days"
/>
<StatCard
label="Returning"
:value="retentionData.summary.returning_players"
icon="bar-chart-3"
note="Last 30 days"
/>
</div>
<!-- Retention curve chart -->
<Panel title="Retention curve (post-wipe return rates)">
<div ref="retentionChart" class="retention-view__chart-area"></div>
<p class="retention-view__chart-note">
<strong>How to read:</strong> Percentage of players who played in the 7 days before a wipe and returned within 24h/48h/72h after the wipe.
</p>
</Panel>
<!-- Wipe details table -->
<Panel title="Wipe details" :flush-body="true">
<div class="retention-view__table-wrap">
<table class="retention-view__table">
<thead>
<tr class="retention-view__thead-row">
<th class="retention-view__th retention-view__th--left">Wipe date</th>
<th class="retention-view__th retention-view__th--right">Pre-wipe players</th>
<th class="retention-view__th retention-view__th--right">24h return</th>
<th class="retention-view__th retention-view__th--right">48h return</th>
<th class="retention-view__th retention-view__th--right">72h return</th>
</tr>
</thead>
<tbody>
<tr
v-for="wipe in retentionData.wipe_metrics"
:key="wipe.wipe_id"
class="retention-view__row"
>
<td class="retention-view__td retention-view__td--date">
{{ new Date(wipe.wipe_date).toLocaleString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
}) }}
</td>
<td class="retention-view__td retention-view__td--num">
{{ wipe.total_players_before_wipe }}
</td>
<td class="retention-view__td retention-view__td--num">
<span class="retention-view__count">{{ wipe.returned_24h }}</span>
<span class="retention-view__pct">({{ safeFixed(wipe.retention_24h_percent, 1) }}%)</span>
</td>
<td class="retention-view__td retention-view__td--num">
<span class="retention-view__count">{{ wipe.returned_48h }}</span>
<span class="retention-view__pct">({{ safeFixed(wipe.retention_48h_percent, 1) }}%)</span>
</td>
<td class="retention-view__td retention-view__td--num">
<span class="retention-view__count">{{ wipe.returned_72h }}</span>
<span class="retention-view__pct">({{ safeFixed(wipe.retention_72h_percent, 1) }}%)</span>
</td>
</tr>
</tbody>
</table>
</div>
</Panel>
</template>
<!-- Empty state -->
<Panel v-else>
<EmptyState
icon="users"
title="No retention data available"
description="Player retention metrics will appear here after wipes are tracked and players join/leave."
/>
</Panel>
</div>
</template>
<style scoped>
.retention-view {
padding: 24px;
display: flex;
flex-direction: column;
gap: 20px;
}
.retention-view__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.retention-view__title {
font-size: var(--text-xl);
font-weight: 700;
color: var(--text-primary);
margin: 0;
}
.retention-view__controls {
display: flex;
align-items: center;
gap: 10px;
}
.retention-view__loading {
display: flex;
align-items: center;
justify-content: center;
padding: 48px 0;
}
.retention-view__loading-text {
font-size: var(--text-sm);
color: var(--text-tertiary);
}
.retention-view__stats {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
@media (min-width: 1024px) {
.retention-view__stats {
grid-template-columns: repeat(4, 1fr);
}
}
.retention-view__chart-area {
height: 384px;
}
.retention-view__chart-note {
margin-top: 12px;
font-size: var(--text-xs);
color: var(--text-tertiary);
line-height: 1.5;
}
.retention-view__table-wrap {
overflow-x: auto;
}
.retention-view__table {
width: 100%;
border-collapse: collapse;
font-size: var(--text-sm);
}
.retention-view__thead-row {
border-bottom: 1px solid var(--border-subtle);
}
.retention-view__th {
padding: 10px 12px;
font-size: var(--text-xs);
font-weight: 500;
color: var(--text-tertiary);
white-space: nowrap;
}
.retention-view__th--left {
text-align: left;
}
.retention-view__th--right {
text-align: right;
}
.retention-view__row {
border-bottom: 1px solid var(--border-subtle);
transition: background var(--dur-fast) var(--ease-standard);
}
.retention-view__row:hover {
background: var(--surface-hover);
}
.retention-view__td {
padding: 12px 12px;
color: var(--text-secondary);
}
.retention-view__td--date {
color: var(--text-secondary);
}
.retention-view__td--num {
text-align: right;
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
}
.retention-view__count {
color: var(--text-primary);
font-weight: 500;
}
.retention-view__pct {
color: var(--text-muted);
font-size: var(--text-xs);
margin-left: 4px;
}
</style>