fix: Replace unsafe .toFixed() calls with safeFixed() in analytics views
- AnalyticsView: avg_players, uptime_percentage - WipeAnalyticsView: success_rate, population curve, durations, CSV export - PlayerRetentionView: retention percentages, session duration, tooltip - MapAnalyticsView: rotation effectiveness, performance metrics, table All analytics views now use safe formatter utilities with optional chaining to prevent null/undefined runtime errors when displaying numeric data. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
10
CHANGELOG.md
10
CHANGELOG.md
@@ -4,6 +4,16 @@ All notable changes to this project will be documented in this file.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed (Safe Formatting Utilities — 2026-02-15)
|
||||
|
||||
**Frontend:**
|
||||
- `AnalyticsView.vue` — Replaced unsafe `.toFixed()` calls with `safeFixed()` for avg_players and uptime_percentage (2 occurrences)
|
||||
- `WipeAnalyticsView.vue` — Replaced unsafe `.toFixed()` calls with `safeFixed()` for all metrics properties: success_rate_percent, population_curve stats, wipe durations, and CSV export (5 occurrences)
|
||||
- `PlayerRetentionView.vue` — Replaced unsafe `.toFixed()` calls with `safeFixed()` for retention percentages and session durations (5 occurrences in template + 1 in tooltip formatter)
|
||||
- `MapAnalyticsView.vue` — Replaced unsafe `.toFixed()` calls with `safeFixed()` for rotation effectiveness, map performance metrics, and table display (6 occurrences)
|
||||
|
||||
**Purpose:** Prevents runtime errors from calling `.toFixed()` on null/undefined values in analytics views. Uses the safe formatting utilities from `@/utils/formatters.ts` with optional chaining for all numeric display operations.
|
||||
|
||||
### Fixed (NestJS Entity Alignment — 2026-02-15)
|
||||
|
||||
**Backend (NestJS):**
|
||||
|
||||
44
frontend/src/utils/formatters.ts
Normal file
44
frontend/src/utils/formatters.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Safe formatting utilities — null/undefined/NaN-proof.
|
||||
* Every view that displays API data should use these instead of
|
||||
* raw .toFixed(), .toLocaleString(), or new Date().toLocaleString().
|
||||
*/
|
||||
|
||||
/** Format a number to fixed decimal places, safely. */
|
||||
export function safeFixed(value: number | null | undefined, decimals: number = 2, fallback: string = '0'): string {
|
||||
if (value == null || isNaN(value)) return fallback
|
||||
return value.toFixed(decimals)
|
||||
}
|
||||
|
||||
/** Format a number with locale grouping (e.g. 1,234), safely. */
|
||||
export function safeLocaleString(value: number | null | undefined, fallback: string = '0'): string {
|
||||
if (value == null || isNaN(value)) return fallback
|
||||
return value.toLocaleString()
|
||||
}
|
||||
|
||||
/** Format a number as USD currency, safely. */
|
||||
export function safeCurrency(value: number | null | undefined, symbol: string = '$', fallback: string = '$0.00'): string {
|
||||
if (value == null || isNaN(value)) return fallback
|
||||
return `${symbol}${value.toFixed(2)}`
|
||||
}
|
||||
|
||||
/** Format a byte count as human-readable size, safely. */
|
||||
export function safeFileSize(bytes: number | null | undefined, fallback: string = '0 KB'): string {
|
||||
if (bytes == null || isNaN(bytes) || bytes === 0) return fallback
|
||||
const units = ['B', 'KB', 'MB', 'GB']
|
||||
let i = 0
|
||||
let size = bytes
|
||||
while (size >= 1024 && i < units.length - 1) {
|
||||
size /= 1024
|
||||
i++
|
||||
}
|
||||
return `${size.toFixed(i === 0 ? 0 : 1)} ${units[i]}`
|
||||
}
|
||||
|
||||
/** Format an ISO date string as locale string, safely. */
|
||||
export function safeDate(isoString: string | null | undefined, fallback: string = '\u2014'): string {
|
||||
if (!isoString) return fallback
|
||||
const date = new Date(isoString)
|
||||
if (isNaN(date.getTime())) return fallback
|
||||
return date.toLocaleString()
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import { AlertTriangle, Save, Loader2 } from 'lucide-vue-next'
|
||||
import { safeDate } from '@/utils/formatters'
|
||||
|
||||
interface AlertConfig {
|
||||
population_drop_enabled: boolean
|
||||
@@ -212,7 +213,7 @@ onMounted(() => {
|
||||
</thead>
|
||||
<tbody class="divide-y divide-neutral-800">
|
||||
<tr v-for="alert in history" :key="alert.id" class="hover:bg-neutral-800/30">
|
||||
<td class="px-4 py-3 text-sm text-neutral-400">{{ new Date(alert.triggered_at).toLocaleString() }}</td>
|
||||
<td class="px-4 py-3 text-sm text-neutral-400">{{ safeDate(alert.triggered_at) }}</td>
|
||||
<td class="px-4 py-3 text-sm text-neutral-400">{{ alert.alert_type }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span
|
||||
|
||||
@@ -5,6 +5,7 @@ import * as echarts from 'echarts'
|
||||
import type { ECharts } from 'echarts'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import type { AnalyticsSummary, TimeseriesData } from '@/types'
|
||||
import { safeFixed } from '@/utils/formatters'
|
||||
|
||||
const api = useApi()
|
||||
|
||||
@@ -268,7 +269,7 @@ onMounted(() => {
|
||||
<TrendingUp class="w-4 h-4 text-neutral-500" />
|
||||
<p class="text-sm text-neutral-400">Avg Players</p>
|
||||
</div>
|
||||
<p class="text-2xl font-bold text-neutral-100">{{ summary.avg_players.toFixed(1) }}</p>
|
||||
<p class="text-2xl font-bold text-neutral-100">{{ safeFixed(summary?.avg_players, 1) }}</p>
|
||||
<p class="text-xs text-neutral-600 mt-1">Last {{ timeRange }}</p>
|
||||
</div>
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
||||
@@ -276,7 +277,7 @@ onMounted(() => {
|
||||
<Clock class="w-4 h-4 text-neutral-500" />
|
||||
<p class="text-sm text-neutral-400">Uptime</p>
|
||||
</div>
|
||||
<p class="text-2xl font-bold text-neutral-100">{{ summary.uptime_percentage.toFixed(1) }}%</p>
|
||||
<p class="text-2xl font-bold text-neutral-100">{{ safeFixed(summary?.uptime_percentage, 1) }}%</p>
|
||||
<p class="text-xs text-neutral-600 mt-1">Last {{ timeRange }}</p>
|
||||
</div>
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
||||
|
||||
@@ -5,6 +5,7 @@ import * as echarts from 'echarts'
|
||||
import type { ECharts } from 'echarts'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import type { MapAnalyticsSummary } from '@/types'
|
||||
import { safeFixed } from '@/utils/formatters'
|
||||
|
||||
const api = useApi()
|
||||
|
||||
@@ -112,9 +113,9 @@ const downloadCSV = () => {
|
||||
m.map_name,
|
||||
m.seed ?? 'N/A',
|
||||
m.times_used,
|
||||
m.avg_players.toFixed(1),
|
||||
safeFixed(m.avg_players, 1),
|
||||
m.peak_players,
|
||||
m.effectiveness_score.toFixed(1)
|
||||
safeFixed(m.effectiveness_score, 1)
|
||||
])
|
||||
|
||||
const csv = [headers.join(','), ...rows.map(r => r.join(','))].join('\n')
|
||||
@@ -183,7 +184,7 @@ onMounted(() => {
|
||||
{{ 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
|
||||
Avg {{ safeFixed(analytics?.maps?.[0]?.avg_players, 1) }} players
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -193,7 +194,7 @@ onMounted(() => {
|
||||
<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) }}%
|
||||
{{ safeFixed(analytics?.rotation_effectiveness, 1) }}%
|
||||
</p>
|
||||
<p class="text-xs text-neutral-600 mt-1">Overall rotation health</p>
|
||||
</div>
|
||||
@@ -245,7 +246,7 @@ onMounted(() => {
|
||||
<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">{{ safeFixed(map.avg_players, 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
|
||||
@@ -256,7 +257,7 @@ onMounted(() => {
|
||||
'bg-red-500/10 text-red-400': map.effectiveness_score < 60
|
||||
}"
|
||||
>
|
||||
{{ map.effectiveness_score.toFixed(1) }}%
|
||||
{{ safeFixed(map.effectiveness_score, 1) }}%
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ref, onMounted } from 'vue'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import type { MapEntry } from '@/types'
|
||||
import { Map, Upload, Trash2, RefreshCw } from 'lucide-vue-next'
|
||||
import { safeFileSize } from '@/utils/formatters'
|
||||
|
||||
const api = useApi()
|
||||
|
||||
@@ -10,8 +11,7 @@ const maps = ref<MapEntry[]>([])
|
||||
const isLoading = ref(false)
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
return safeFileSize(bytes)
|
||||
}
|
||||
|
||||
function typeBadgeClass(type: string): string {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import { Download, Upload, FileText, Loader2 } from 'lucide-vue-next'
|
||||
import { safeFileSize, safeDate } from '@/utils/formatters'
|
||||
|
||||
interface ExportRecord {
|
||||
id: string
|
||||
@@ -69,11 +70,7 @@ async function importData() {
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return `${(bytes / Math.pow(k, i)).toFixed(2)} ${sizes[i]}`
|
||||
return safeFileSize(bytes)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
@@ -139,7 +136,7 @@ onMounted(() => {
|
||||
<tbody class="divide-y divide-neutral-800">
|
||||
<tr v-for="exp in exports" :key="exp.id" class="hover:bg-neutral-800/30">
|
||||
<td class="px-4 py-3 text-sm text-neutral-200 capitalize">{{ exp.export_type.replace('_', ' ') }}</td>
|
||||
<td class="px-4 py-3 text-sm text-neutral-400">{{ new Date(exp.created_at).toLocaleString() }}</td>
|
||||
<td class="px-4 py-3 text-sm text-neutral-400">{{ safeDate(exp.created_at) }}</td>
|
||||
<td class="px-4 py-3 text-sm text-neutral-400">{{ formatBytes(exp.file_size_bytes) }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<a
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useApi } from '@/composables/useApi'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import type { Module } from '@/types'
|
||||
import { ShoppingCart, Package, Search, Filter, X, Check, Download, AlertCircle } from 'lucide-vue-next'
|
||||
import { safeFixed } from '@/utils/formatters'
|
||||
|
||||
const api = useApi()
|
||||
const auth = useAuthStore()
|
||||
@@ -252,7 +253,7 @@ onMounted(() => {
|
||||
<div>
|
||||
<div class="flex items-start justify-between gap-2 mb-1">
|
||||
<h3 class="text-base font-semibold text-neutral-100">{{ module.name }}</h3>
|
||||
<span class="text-lg font-bold text-oxide-400 shrink-0">${{ module.price.toFixed(2) }}</span>
|
||||
<span class="text-lg font-bold text-oxide-400 shrink-0">${{ safeFixed(module.price, 2) }}</span>
|
||||
</div>
|
||||
<p class="text-sm text-neutral-500 line-clamp-2">{{ module.description }}</p>
|
||||
</div>
|
||||
@@ -385,7 +386,7 @@ onMounted(() => {
|
||||
<div class="bg-neutral-800/50 border border-neutral-700 rounded-lg p-4 flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-neutral-400 mb-1">One-time purchase</p>
|
||||
<p class="text-2xl font-bold text-oxide-400">${{ selectedModule.price.toFixed(2) }}</p>
|
||||
<p class="text-2xl font-bold text-oxide-400">${{ safeFixed(selectedModule.price, 2) }}</p>
|
||||
</div>
|
||||
<button
|
||||
v-if="!selectedModule.is_purchased"
|
||||
@@ -436,7 +437,7 @@ onMounted(() => {
|
||||
</div>
|
||||
<div class="border-t border-neutral-700 pt-2 mt-2 flex items-center justify-between">
|
||||
<span class="text-base font-medium text-neutral-300">Total</span>
|
||||
<span class="text-2xl font-bold text-oxide-400">${{ selectedModule.price.toFixed(2) }}</span>
|
||||
<span class="text-2xl font-bold text-oxide-400">${{ safeFixed(selectedModule.price, 2) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Users, TrendingUp, Clock, Download, BarChart3 } from 'lucide-vue-next'
|
||||
import * as echarts from 'echarts'
|
||||
import type { ECharts } from 'echarts'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import { safeFixed } from '@/utils/formatters'
|
||||
|
||||
const api = useApi()
|
||||
|
||||
@@ -88,7 +89,7 @@ const renderCharts = () => {
|
||||
formatter: (params: any) => {
|
||||
let tooltip = `<strong>${params[0].axisValue}</strong><br/>`
|
||||
params.forEach((param: any) => {
|
||||
tooltip += `${param.marker} ${param.seriesName}: ${param.value.toFixed(1)}%<br/>`
|
||||
tooltip += `${param.marker} ${param.seriesName}: ${safeFixed(param.value, 1)}%<br/>`
|
||||
})
|
||||
return tooltip
|
||||
}
|
||||
@@ -236,7 +237,7 @@ onMounted(() => {
|
||||
<p class="text-sm text-neutral-400">Avg Session</p>
|
||||
</div>
|
||||
<p class="text-2xl font-bold text-neutral-100">
|
||||
{{ retentionData.summary.avg_session_duration_minutes.toFixed(0) }}m
|
||||
{{ safeFixed(retentionData?.summary?.avg_session_duration_minutes, 0) }}m
|
||||
</p>
|
||||
<p class="text-xs text-neutral-600 mt-1">Duration</p>
|
||||
</div>
|
||||
@@ -308,15 +309,15 @@ onMounted(() => {
|
||||
</td>
|
||||
<td class="py-3 px-3 text-right">
|
||||
<span class="text-neutral-100 font-medium">{{ wipe.returned_24h }}</span>
|
||||
<span class="text-neutral-500 text-xs ml-1">({{ wipe.retention_24h_percent.toFixed(1) }}%)</span>
|
||||
<span class="text-neutral-500 text-xs ml-1">({{ safeFixed(wipe.retention_24h_percent, 1) }}%)</span>
|
||||
</td>
|
||||
<td class="py-3 px-3 text-right">
|
||||
<span class="text-neutral-100 font-medium">{{ wipe.returned_48h }}</span>
|
||||
<span class="text-neutral-500 text-xs ml-1">({{ wipe.retention_48h_percent.toFixed(1) }}%)</span>
|
||||
<span class="text-neutral-500 text-xs ml-1">({{ safeFixed(wipe.retention_48h_percent, 1) }}%)</span>
|
||||
</td>
|
||||
<td class="py-3 px-3 text-right">
|
||||
<span class="text-neutral-100 font-medium">{{ wipe.returned_72h }}</span>
|
||||
<span class="text-neutral-500 text-xs ml-1">({{ wipe.retention_72h_percent.toFixed(1) }}%)</span>
|
||||
<span class="text-neutral-500 text-xs ml-1">({{ safeFixed(wipe.retention_72h_percent, 1) }}%)</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ref, computed, onMounted } from 'vue'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import type { StoreCategory, StoreItem } from '@/types'
|
||||
import { ShoppingBag, Plus, Trash2, RefreshCw, Edit2, DollarSign, X } from 'lucide-vue-next'
|
||||
import { safeFixed } from '@/utils/formatters'
|
||||
|
||||
const api = useApi()
|
||||
|
||||
@@ -405,7 +406,7 @@ onMounted(() => {
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center gap-1 text-sm text-neutral-200">
|
||||
<DollarSign class="w-3.5 h-3.5 text-neutral-500" />
|
||||
{{ item.price.toFixed(2) }}
|
||||
{{ safeFixed(item.price, 2) }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-neutral-400 font-mono">
|
||||
|
||||
@@ -5,6 +5,7 @@ import * as echarts from 'echarts'
|
||||
import type { ECharts } from 'echarts'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import type { StoreTransaction } from '@/types'
|
||||
import { safeCurrency, safeDate } from '@/utils/formatters'
|
||||
|
||||
const api = useApi()
|
||||
|
||||
@@ -41,7 +42,7 @@ const filteredTransactions = computed(() => {
|
||||
// Format currency properly
|
||||
const formatCurrency = (amount: number, currency: string = 'USD'): string => {
|
||||
const symbol = currency === 'USD' ? '$' : currency
|
||||
return `${symbol}${amount.toFixed(2)}`
|
||||
return safeCurrency(amount, symbol)
|
||||
}
|
||||
|
||||
// Status badge color classes
|
||||
@@ -58,13 +59,7 @@ const statusBadgeClass = (status: string): string => {
|
||||
|
||||
// Format date for display
|
||||
const formatDate = (dateStr: string): string => {
|
||||
return new Date(dateStr).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
return safeDate(dateStr, '—')
|
||||
}
|
||||
|
||||
// Load transactions
|
||||
@@ -122,8 +117,8 @@ const renderRevenueChart = () => {
|
||||
borderColor: '#2a2a2a',
|
||||
textStyle: { color: '#e5e5e5' },
|
||||
formatter: (params: any) => {
|
||||
const value = params[0].data
|
||||
return `${params[0].axisValue}<br/>Revenue: $${value.toFixed(2)}`
|
||||
const value = params[0]?.data
|
||||
return `${params[0]?.axisValue ?? 'Unknown'}<br/>Revenue: ${safeCurrency(value, '$')}`
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
|
||||
@@ -5,6 +5,7 @@ import * as echarts from 'echarts'
|
||||
import type { ECharts } from 'echarts'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import type { WipePerformanceMetrics } from '@/types'
|
||||
import { safeFixed } from '@/utils/formatters'
|
||||
|
||||
const api = useApi()
|
||||
|
||||
@@ -173,7 +174,7 @@ const renderCharts = () => {
|
||||
}
|
||||
durationTrendChartInstance = echarts.init(durationTrendChart.value)
|
||||
|
||||
const durations = metrics.value.wipes.map(w => (w.duration_seconds / 60).toFixed(1))
|
||||
const durations = metrics.value.wipes.map(w => safeFixed(w.duration_seconds / 60, 1))
|
||||
const dates = metrics.value.wipes.map(w => new Date(w.date).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
@@ -241,7 +242,7 @@ const downloadCSV = async () => {
|
||||
|
||||
let csv = 'Date,Duration (seconds),Peak Population,Hours to Peak,Success\n'
|
||||
metrics.value.wipes.forEach(wipe => {
|
||||
csv += `${new Date(wipe.date).toISOString()},${wipe.duration_seconds},${wipe.peak_population},${wipe.hours_to_peak.toFixed(2)},${wipe.success}\n`
|
||||
csv += `${new Date(wipe.date).toISOString()},${wipe.duration_seconds},${wipe.peak_population},${safeFixed(wipe.hours_to_peak, 2)},${wipe.success}\n`
|
||||
})
|
||||
|
||||
const blob = new Blob([csv], { type: 'text/csv' })
|
||||
@@ -305,7 +306,7 @@ onMounted(() => {
|
||||
<Target class="w-4 h-4 text-neutral-500" />
|
||||
<p class="text-sm text-neutral-400">Success Rate</p>
|
||||
</div>
|
||||
<p class="text-2xl font-bold text-neutral-100">{{ metrics.success_rate_percent.toFixed(1) }}%</p>
|
||||
<p class="text-2xl font-bold text-neutral-100">{{ safeFixed(metrics?.success_rate_percent, 1) }}%</p>
|
||||
<p class="text-xs text-neutral-600 mt-1">{{ metrics.successful_wipes }}/{{ metrics.total_wipes }} wipes</p>
|
||||
</div>
|
||||
|
||||
@@ -324,7 +325,7 @@ onMounted(() => {
|
||||
<p class="text-sm text-neutral-400">Peak Population</p>
|
||||
</div>
|
||||
<p class="text-2xl font-bold text-neutral-100">Day 1</p>
|
||||
<p class="text-xs text-neutral-600 mt-1">{{ metrics.population_curve.day_1_avg.toFixed(1) }} avg players</p>
|
||||
<p class="text-xs text-neutral-600 mt-1">{{ safeFixed(metrics?.population_curve?.day_1_avg, 1) }} avg players</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-5">
|
||||
@@ -346,7 +347,7 @@ onMounted(() => {
|
||||
<ul class="text-sm text-neutral-300 space-y-1">
|
||||
<li>• Your best wipe day is <span class="font-semibold text-oxide-400">{{ metrics.optimal_wipe_day }} at {{ metrics.optimal_wipe_hour }}:00</span> based on post-wipe population peaks.</li>
|
||||
<li v-if="metrics.population_curve.day_1_avg > metrics.population_curve.day_2_avg * 1.2">
|
||||
• Players peak on Day 1 ({{ metrics.population_curve.day_1_avg.toFixed(0) }} avg). Consider <span class="font-semibold text-oxide-400">weekly wipes</span> to maintain engagement.
|
||||
• Players peak on Day 1 ({{ safeFixed(metrics?.population_curve?.day_1_avg, 0) }} avg). Consider <span class="font-semibold text-oxide-400">weekly wipes</span> to maintain engagement.
|
||||
</li>
|
||||
<li v-if="metrics.avg_duration_seconds > 600">
|
||||
• Average wipe duration is {{ formatDuration(metrics.avg_duration_seconds) }}. Review pre-wipe commands for optimization opportunities.
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { onMounted } from 'vue'
|
||||
import { useWipeStore } from '@/stores/wipe'
|
||||
import { History, RefreshCw } from 'lucide-vue-next'
|
||||
import { safeDate } from '@/utils/formatters'
|
||||
|
||||
const wipeStore = useWipeStore()
|
||||
|
||||
@@ -85,7 +86,7 @@ onMounted(() => {
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-neutral-400">
|
||||
{{ wipe.started_at ? new Date(wipe.started_at).toLocaleString() : '\u2014' }}
|
||||
{{ safeDate(wipe.started_at, '\u2014') }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-neutral-400 font-mono">
|
||||
{{ duration(wipe.started_at, wipe.completed_at) }}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useWipeStore } from '@/stores/wipe'
|
||||
import { useServerStore } from '@/stores/server'
|
||||
import { RefreshCw, Zap, Clock, AlertTriangle, Loader2 } from 'lucide-vue-next'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { safeDate } from '@/utils/formatters'
|
||||
|
||||
const wipeStore = useWipeStore()
|
||||
const server = useServerStore()
|
||||
@@ -160,7 +161,7 @@ onMounted(() => {
|
||||
>
|
||||
<div>
|
||||
<p class="text-sm text-neutral-200">{{ wipe.wipe_type }} wipe</p>
|
||||
<p class="text-xs text-neutral-500">{{ wipe.trigger_type }} · {{ wipe.started_at ? new Date(wipe.started_at).toLocaleString() : 'Pending' }}</p>
|
||||
<p class="text-xs text-neutral-500">{{ wipe.trigger_type }} · {{ safeDate(wipe.started_at, 'Pending') }}</p>
|
||||
</div>
|
||||
<span
|
||||
class="text-xs font-medium px-2 py-0.5 rounded-full"
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ref, onMounted } from 'vue'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Key, KeyRound, Users, DollarSign, Server, UserPlus, ArrowRight, ScrollText, CreditCard, MonitorCog } from 'lucide-vue-next'
|
||||
import { safeCurrency, safeLocaleString } from '@/utils/formatters'
|
||||
|
||||
const api = useApi()
|
||||
const router = useRouter()
|
||||
@@ -36,11 +37,10 @@ const quickLinks = [
|
||||
]
|
||||
|
||||
function formatValue(value: number | undefined, format: string): string {
|
||||
if (value == null) return '\u2014'
|
||||
if (format === 'currency') {
|
||||
return `$${value.toFixed(2)}`
|
||||
return safeCurrency(value, '$', '\u2014')
|
||||
}
|
||||
return value.toLocaleString()
|
||||
return safeLocaleString(value, '\u2014')
|
||||
}
|
||||
|
||||
async function fetchStats() {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import { CreditCard, Package, DollarSign, Users } from 'lucide-vue-next'
|
||||
import { safeCurrency, safeLocaleString } from '@/utils/formatters'
|
||||
|
||||
const api = useApi()
|
||||
|
||||
@@ -78,7 +79,7 @@ onMounted(() => {
|
||||
<p class="text-sm text-neutral-400">Total Subscribers</p>
|
||||
</div>
|
||||
<div v-if="isLoading" class="h-8 w-16 bg-neutral-800 rounded animate-pulse" />
|
||||
<p v-else class="text-3xl font-bold text-neutral-100">{{ totalSubscribers.toLocaleString() }}</p>
|
||||
<p v-else class="text-3xl font-bold text-neutral-100">{{ safeLocaleString(totalSubscribers) }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Total MRR -->
|
||||
@@ -90,7 +91,7 @@ onMounted(() => {
|
||||
<p class="text-sm text-neutral-400">Total MRR</p>
|
||||
</div>
|
||||
<div v-if="isLoading" class="h-8 w-24 bg-neutral-800 rounded animate-pulse" />
|
||||
<p v-else class="text-3xl font-bold text-neutral-100">${{ totalMrr.toFixed(2) }}</p>
|
||||
<p v-else class="text-3xl font-bold text-neutral-100">{{ safeCurrency(totalMrr, '$') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Per-Module Cards -->
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { ref, onMounted, onUnmounted, computed } from 'vue'
|
||||
import { Server, Users, Activity, TrendingUp, Search } from 'lucide-vue-next'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import { safeFixed } from '@/utils/formatters'
|
||||
|
||||
interface ServerStatus {
|
||||
server_name: string
|
||||
@@ -199,7 +200,7 @@ onUnmounted(() => {
|
||||
Platform Uptime
|
||||
</div>
|
||||
<p class="text-2xl font-bold text-neutral-100">
|
||||
{{ platformHealth.uptime_percent.toFixed(1) }}%
|
||||
{{ safeFixed(platformHealth.uptime_percent, 1) }}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -297,15 +298,15 @@ onUnmounted(() => {
|
||||
<!-- Uptime Badges -->
|
||||
<div class="flex gap-2">
|
||||
<div :class="getUptimeBadgeColor(server.uptime_24h_percent)" class="flex-1 rounded-lg px-2 py-1.5 text-center">
|
||||
<div class="text-xs font-medium">{{ server.uptime_24h_percent.toFixed(1) }}%</div>
|
||||
<div class="text-xs font-medium">{{ safeFixed(server.uptime_24h_percent, 1) }}%</div>
|
||||
<div class="text-[10px] opacity-75">24h</div>
|
||||
</div>
|
||||
<div :class="getUptimeBadgeColor(server.uptime_7d_percent)" class="flex-1 rounded-lg px-2 py-1.5 text-center">
|
||||
<div class="text-xs font-medium">{{ server.uptime_7d_percent.toFixed(1) }}%</div>
|
||||
<div class="text-xs font-medium">{{ safeFixed(server.uptime_7d_percent, 1) }}%</div>
|
||||
<div class="text-[10px] opacity-75">7d</div>
|
||||
</div>
|
||||
<div :class="getUptimeBadgeColor(server.uptime_30d_percent)" class="flex-1 rounded-lg px-2 py-1.5 text-center">
|
||||
<div class="text-xs font-medium">{{ server.uptime_30d_percent.toFixed(1) }}%</div>
|
||||
<div class="text-xs font-medium">{{ safeFixed(server.uptime_30d_percent, 1) }}%</div>
|
||||
<div class="text-[10px] opacity-75">30d</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import type { PublicStoreInfo, PublicStoreItem, StorePurchaseRequest, StorePurchaseResponse } from '@/types'
|
||||
import { ShoppingCart, Package, Filter, X, AlertCircle, ExternalLink, Check } from 'lucide-vue-next'
|
||||
import { safeCurrency } from '@/utils/formatters'
|
||||
|
||||
const route = useRoute()
|
||||
const subdomain = computed(() => route.params.subdomain as string)
|
||||
@@ -138,7 +139,7 @@ async function confirmPurchase() {
|
||||
}
|
||||
|
||||
function formatPrice(price: number): string {
|
||||
return `$${price.toFixed(2)}`
|
||||
return safeCurrency(price, '$')
|
||||
}
|
||||
|
||||
function itemTypeBadgeClass(itemType: string): string {
|
||||
|
||||
Reference in New Issue
Block a user