diff --git a/CHANGELOG.md b/CHANGELOG.md index cccdbd3..805c014 100644 --- a/CHANGELOG.md +++ b/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):** diff --git a/frontend/src/utils/formatters.ts b/frontend/src/utils/formatters.ts new file mode 100644 index 0000000..1fb45b6 --- /dev/null +++ b/frontend/src/utils/formatters.ts @@ -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() +} diff --git a/frontend/src/views/admin/AlertsView.vue b/frontend/src/views/admin/AlertsView.vue index d98e379..ee84e2a 100644 --- a/frontend/src/views/admin/AlertsView.vue +++ b/frontend/src/views/admin/AlertsView.vue @@ -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(() => { - {{ new Date(alert.triggered_at).toLocaleString() }} + {{ safeDate(alert.triggered_at) }} {{ alert.alert_type }} {

Avg Players

-

{{ summary.avg_players.toFixed(1) }}

+

{{ safeFixed(summary?.avg_players, 1) }}

Last {{ timeRange }}

@@ -276,7 +277,7 @@ onMounted(() => {

Uptime

-

{{ summary.uptime_percentage.toFixed(1) }}%

+

{{ safeFixed(summary?.uptime_percentage, 1) }}%

Last {{ timeRange }}

diff --git a/frontend/src/views/admin/MapAnalyticsView.vue b/frontend/src/views/admin/MapAnalyticsView.vue index 3d9d02b..ce41893 100644 --- a/frontend/src/views/admin/MapAnalyticsView.vue +++ b/frontend/src/views/admin/MapAnalyticsView.vue @@ -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' }}

- Avg {{ analytics.maps[0]?.avg_players.toFixed(1) }} players + Avg {{ safeFixed(analytics?.maps?.[0]?.avg_players, 1) }} players

@@ -193,7 +194,7 @@ onMounted(() => {

Rotation Effectiveness

- {{ analytics.rotation_effectiveness.toFixed(1) }}% + {{ safeFixed(analytics?.rotation_effectiveness, 1) }}%

Overall rotation health

@@ -245,7 +246,7 @@ onMounted(() => { {{ map.map_name }} {{ map.seed ?? '—' }} {{ map.times_used }} - {{ map.avg_players.toFixed(1) }} + {{ safeFixed(map.avg_players, 1) }} {{ map.peak_players }} { 'bg-red-500/10 text-red-400': map.effectiveness_score < 60 }" > - {{ map.effectiveness_score.toFixed(1) }}% + {{ safeFixed(map.effectiveness_score, 1) }}% diff --git a/frontend/src/views/admin/MapsView.vue b/frontend/src/views/admin/MapsView.vue index 7f5e346..6378f7c 100644 --- a/frontend/src/views/admin/MapsView.vue +++ b/frontend/src/views/admin/MapsView.vue @@ -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([]) 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 { diff --git a/frontend/src/views/admin/MigrationView.vue b/frontend/src/views/admin/MigrationView.vue index 5d735c9..05a9507 100644 --- a/frontend/src/views/admin/MigrationView.vue +++ b/frontend/src/views/admin/MigrationView.vue @@ -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(() => { {{ exp.export_type.replace('_', ' ') }} - {{ new Date(exp.created_at).toLocaleString() }} + {{ safeDate(exp.created_at) }} {{ formatBytes(exp.file_size_bytes) }} {

{{ module.name }}

- ${{ module.price.toFixed(2) }} + ${{ safeFixed(module.price, 2) }}

{{ module.description }}

@@ -385,7 +386,7 @@ onMounted(() => {

One-time purchase

-

${{ selectedModule.price.toFixed(2) }}

+

${{ safeFixed(selectedModule.price, 2) }}

Total - ${{ selectedModule.price.toFixed(2) }} + ${{ safeFixed(selectedModule.price, 2) }}
diff --git a/frontend/src/views/admin/PlayerRetentionView.vue b/frontend/src/views/admin/PlayerRetentionView.vue index 971b37f..fcc0c14 100644 --- a/frontend/src/views/admin/PlayerRetentionView.vue +++ b/frontend/src/views/admin/PlayerRetentionView.vue @@ -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 = `${params[0].axisValue}
` params.forEach((param: any) => { - tooltip += `${param.marker} ${param.seriesName}: ${param.value.toFixed(1)}%
` + tooltip += `${param.marker} ${param.seriesName}: ${safeFixed(param.value, 1)}%
` }) return tooltip } @@ -236,7 +237,7 @@ onMounted(() => {

Avg Session

- {{ retentionData.summary.avg_session_duration_minutes.toFixed(0) }}m + {{ safeFixed(retentionData?.summary?.avg_session_duration_minutes, 0) }}m

Duration

@@ -308,15 +309,15 @@ onMounted(() => { {{ wipe.returned_24h }} - ({{ wipe.retention_24h_percent.toFixed(1) }}%) + ({{ safeFixed(wipe.retention_24h_percent, 1) }}%) {{ wipe.returned_48h }} - ({{ wipe.retention_48h_percent.toFixed(1) }}%) + ({{ safeFixed(wipe.retention_48h_percent, 1) }}%) {{ wipe.returned_72h }} - ({{ wipe.retention_72h_percent.toFixed(1) }}%) + ({{ safeFixed(wipe.retention_72h_percent, 1) }}%) diff --git a/frontend/src/views/admin/StoreItemsView.vue b/frontend/src/views/admin/StoreItemsView.vue index 0e3dbf2..efc0c5e 100644 --- a/frontend/src/views/admin/StoreItemsView.vue +++ b/frontend/src/views/admin/StoreItemsView.vue @@ -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(() => {
- {{ item.price.toFixed(2) }} + {{ safeFixed(item.price, 2) }}
diff --git a/frontend/src/views/admin/StoreRevenueView.vue b/frontend/src/views/admin/StoreRevenueView.vue index 3d22cac..7ae481c 100644 --- a/frontend/src/views/admin/StoreRevenueView.vue +++ b/frontend/src/views/admin/StoreRevenueView.vue @@ -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}
Revenue: $${value.toFixed(2)}` + const value = params[0]?.data + return `${params[0]?.axisValue ?? 'Unknown'}
Revenue: ${safeCurrency(value, '$')}` } }, grid: { diff --git a/frontend/src/views/admin/WipeAnalyticsView.vue b/frontend/src/views/admin/WipeAnalyticsView.vue index 59ad7d9..1bd6374 100644 --- a/frontend/src/views/admin/WipeAnalyticsView.vue +++ b/frontend/src/views/admin/WipeAnalyticsView.vue @@ -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(() => {

Success Rate

-

{{ metrics.success_rate_percent.toFixed(1) }}%

+

{{ safeFixed(metrics?.success_rate_percent, 1) }}%

{{ metrics.successful_wipes }}/{{ metrics.total_wipes }} wipes

@@ -324,7 +325,7 @@ onMounted(() => {

Peak Population

Day 1

-

{{ metrics.population_curve.day_1_avg.toFixed(1) }} avg players

+

{{ safeFixed(metrics?.population_curve?.day_1_avg, 1) }} avg players

@@ -346,7 +347,7 @@ onMounted(() => {
  • • Your best wipe day is {{ metrics.optimal_wipe_day }} at {{ metrics.optimal_wipe_hour }}:00 based on post-wipe population peaks.
  • - • Players peak on Day 1 ({{ metrics.population_curve.day_1_avg.toFixed(0) }} avg). Consider weekly wipes to maintain engagement. + • Players peak on Day 1 ({{ safeFixed(metrics?.population_curve?.day_1_avg, 0) }} avg). Consider weekly wipes to maintain engagement.
  • • Average wipe duration is {{ formatDuration(metrics.avg_duration_seconds) }}. Review pre-wipe commands for optimization opportunities. diff --git a/frontend/src/views/admin/WipeHistoryView.vue b/frontend/src/views/admin/WipeHistoryView.vue index f0c18e2..722b493 100644 --- a/frontend/src/views/admin/WipeHistoryView.vue +++ b/frontend/src/views/admin/WipeHistoryView.vue @@ -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(() => { - {{ wipe.started_at ? new Date(wipe.started_at).toLocaleString() : '\u2014' }} + {{ safeDate(wipe.started_at, '\u2014') }} {{ duration(wipe.started_at, wipe.completed_at) }} diff --git a/frontend/src/views/admin/WipesView.vue b/frontend/src/views/admin/WipesView.vue index d0c291e..93e3692 100644 --- a/frontend/src/views/admin/WipesView.vue +++ b/frontend/src/views/admin/WipesView.vue @@ -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(() => { >

    {{ wipe.wipe_type }} wipe

    -

    {{ wipe.trigger_type }} · {{ wipe.started_at ? new Date(wipe.started_at).toLocaleString() : 'Pending' }}

    +

    {{ wipe.trigger_type }} · {{ safeDate(wipe.started_at, 'Pending') }}

    {

    Total Subscribers

-

{{ totalSubscribers.toLocaleString() }}

+

{{ safeLocaleString(totalSubscribers) }}

@@ -90,7 +91,7 @@ onMounted(() => {

Total MRR

-

${{ totalMrr.toFixed(2) }}

+

{{ safeCurrency(totalMrr, '$') }}

diff --git a/frontend/src/views/public/StatusPageView.vue b/frontend/src/views/public/StatusPageView.vue index 3436435..cb93612 100644 --- a/frontend/src/views/public/StatusPageView.vue +++ b/frontend/src/views/public/StatusPageView.vue @@ -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

- {{ platformHealth.uptime_percent.toFixed(1) }}% + {{ safeFixed(platformHealth.uptime_percent, 1) }}%

@@ -297,15 +298,15 @@ onUnmounted(() => {
-
{{ server.uptime_24h_percent.toFixed(1) }}%
+
{{ safeFixed(server.uptime_24h_percent, 1) }}%
24h
-
{{ server.uptime_7d_percent.toFixed(1) }}%
+
{{ safeFixed(server.uptime_7d_percent, 1) }}%
7d
-
{{ server.uptime_30d_percent.toFixed(1) }}%
+
{{ safeFixed(server.uptime_30d_percent, 1) }}%
30d
diff --git a/frontend/src/views/public/StoreView.vue b/frontend/src/views/public/StoreView.vue index 62599cc..8988a67 100644 --- a/frontend/src/views/public/StoreView.vue +++ b/frontend/src/views/public/StoreView.vue @@ -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 {