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:
Vantz Stockwell
2026-02-15 21:56:04 -05:00
parent daa9c3035f
commit 26e717ac96
18 changed files with 111 additions and 53 deletions

View File

@@ -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):**

View 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()
}

View File

@@ -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

View File

@@ -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">

View File

@@ -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>

View File

@@ -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 {

View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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">

View File

@@ -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: {

View File

@@ -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.

View File

@@ -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) }}

View File

@@ -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 }} &middot; {{ wipe.started_at ? new Date(wipe.started_at).toLocaleString() : 'Pending' }}</p>
<p class="text-xs text-neutral-500">{{ wipe.trigger_type }} &middot; {{ safeDate(wipe.started_at, 'Pending') }}</p>
</div>
<span
class="text-xs font-medium px-2 py-0.5 rounded-full"

View File

@@ -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() {

View File

@@ -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 -->

View File

@@ -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>

View File

@@ -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 {