refactor: migrate dashboard to SWR hooks, eliminate all any[] state
Replace 22 manual useState + useEffect + setInterval polling with 11 focused SWR hooks. Removes ~85 lines of polling/visibility logic that SWR handles natively. All any[] types replaced with proper interfaces (TopPage, CountryStat, BrowserStat, etc.). Organization state in layout typed as OrganizationMember[]. Resolves F-7, F-8, F-15 from audit report. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Improved
|
||||||
|
|
||||||
|
- **Faster, smarter dashboard data loading.** Your dashboard now loads each section independently using an intelligent caching strategy. Data refreshes happen automatically in the background, and when you switch tabs the app pauses updates to save resources — resuming instantly when you return. This replaces the previous approach where everything loaded in one large batch, meaning your charts, visitor maps, and stats now appear faster and update more reliably.
|
||||||
|
- **Better data accuracy across the dashboard.** All data displayed on the dashboard — pages, locations, devices, referrers, performance metrics, and goals — is now fully typed end-to-end. This eliminates an entire class of potential display bugs where data could be misinterpreted between the server and your screen.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **Tracking script now works on all tracked websites.** Page views were silently failing to record when the tracking script ran on your website. Two issues were at play: the backend was rejecting analytics data sent from tracked sites, and even after that was resolved, events were silently dropped during processing because they were missing a required identifier. Both are now fixed — your dashboard receives visits from all registered domains as expected.
|
- **Tracking script now works on all tracked websites.** Page views were silently failing to record when the tracking script ran on your website. Two issues were at play: the backend was rejecting analytics data sent from tracked sites, and even after that was resolved, events were silently dropped during processing because they were missing a required identifier. Both are now fixed — your dashboard receives visits from all registered domains as expected.
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { useOnlineStatus } from '@/lib/hooks/useOnlineStatus'
|
|||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { logger } from '@/lib/utils/logger'
|
import { logger } from '@/lib/utils/logger'
|
||||||
import { getUserOrganizations, switchContext } from '@/lib/api/organization'
|
import { getUserOrganizations, switchContext, type OrganizationMember } from '@/lib/api/organization'
|
||||||
import { setSessionAction } from '@/app/actions/auth'
|
import { setSessionAction } from '@/app/actions/auth'
|
||||||
import { LoadingOverlay } from '@ciphera-net/ui'
|
import { LoadingOverlay } from '@ciphera-net/ui'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
@@ -48,7 +48,7 @@ export default function LayoutContent({ children }: { children: React.ReactNode
|
|||||||
const auth = useAuth()
|
const auth = useAuth()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const isOnline = useOnlineStatus()
|
const isOnline = useOnlineStatus()
|
||||||
const [orgs, setOrgs] = useState<any[]>([])
|
const [orgs, setOrgs] = useState<OrganizationMember[]>([])
|
||||||
const [isSwitchingOrg, setIsSwitchingOrg] = useState(() => {
|
const [isSwitchingOrg, setIsSwitchingOrg] = useState(() => {
|
||||||
if (typeof window === 'undefined') return false
|
if (typeof window === 'undefined') return false
|
||||||
return sessionStorage.getItem(ORG_SWITCH_KEY) === 'true'
|
return sessionStorage.getItem(ORG_SWITCH_KEY) === 'true'
|
||||||
|
|||||||
@@ -2,15 +2,13 @@
|
|||||||
|
|
||||||
import { useAuth } from '@/lib/auth/context'
|
import { useAuth } from '@/lib/auth/context'
|
||||||
import { logger } from '@/lib/utils/logger'
|
import { logger } from '@/lib/utils/logger'
|
||||||
import { useCallback, useEffect, useState, useRef } from 'react'
|
import { useEffect, useState, useMemo } from 'react'
|
||||||
import { useParams, useRouter } from 'next/navigation'
|
import { useParams, useRouter } from 'next/navigation'
|
||||||
import { motion } from 'framer-motion'
|
import { motion } from 'framer-motion'
|
||||||
import { getSite, type Site } from '@/lib/api/sites'
|
import { getPerformanceByPage, type Stats, type DailyStat } from '@/lib/api/stats'
|
||||||
import { getStats, getRealtime, getDailyStats, getTopPages, getTopReferrers, getCountries, getCities, getRegions, getBrowsers, getOS, getDevices, getScreenResolutions, getEntryPages, getExitPages, getDashboard, getCampaigns, getPerformanceByPage, type Stats, type DailyStat, type PerformanceByPageStat } from '@/lib/api/stats'
|
import { getDateRange } from '@ciphera-net/ui'
|
||||||
import { formatNumber, formatDuration, getDateRange } from '@ciphera-net/ui'
|
|
||||||
import { toast } from '@ciphera-net/ui'
|
import { toast } from '@ciphera-net/ui'
|
||||||
import { getAuthErrorMessage } from '@ciphera-net/ui'
|
import { Button } from '@ciphera-net/ui'
|
||||||
import { LoadingOverlay, Button } from '@ciphera-net/ui'
|
|
||||||
import { Select, DatePicker, DownloadIcon } from '@ciphera-net/ui'
|
import { Select, DatePicker, DownloadIcon } from '@ciphera-net/ui'
|
||||||
import { DashboardSkeleton, useMinimumLoading } from '@/components/skeletons'
|
import { DashboardSkeleton, useMinimumLoading } from '@/components/skeletons'
|
||||||
import ExportModal from '@/components/dashboard/ExportModal'
|
import ExportModal from '@/components/dashboard/ExportModal'
|
||||||
@@ -22,6 +20,45 @@ import Chart from '@/components/dashboard/Chart'
|
|||||||
import PerformanceStats from '@/components/dashboard/PerformanceStats'
|
import PerformanceStats from '@/components/dashboard/PerformanceStats'
|
||||||
import GoalStats from '@/components/dashboard/GoalStats'
|
import GoalStats from '@/components/dashboard/GoalStats'
|
||||||
import Campaigns from '@/components/dashboard/Campaigns'
|
import Campaigns from '@/components/dashboard/Campaigns'
|
||||||
|
import {
|
||||||
|
useDashboardOverview,
|
||||||
|
useDashboardPages,
|
||||||
|
useDashboardLocations,
|
||||||
|
useDashboardDevices,
|
||||||
|
useDashboardReferrers,
|
||||||
|
useDashboardPerformance,
|
||||||
|
useDashboardGoals,
|
||||||
|
useRealtime,
|
||||||
|
useStats,
|
||||||
|
useDailyStats,
|
||||||
|
useCampaigns,
|
||||||
|
} from '@/lib/swr/dashboard'
|
||||||
|
|
||||||
|
function loadSavedSettings(): {
|
||||||
|
type?: string
|
||||||
|
dateRange?: { start: string; end: string }
|
||||||
|
todayInterval?: 'minute' | 'hour'
|
||||||
|
multiDayInterval?: 'hour' | 'day'
|
||||||
|
} | null {
|
||||||
|
if (typeof window === 'undefined') return null
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem('pulse_dashboard_settings')
|
||||||
|
return saved ? JSON.parse(saved) : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInitialDateRange(): { start: string; end: string } {
|
||||||
|
const settings = loadSavedSettings()
|
||||||
|
if (settings?.type === 'today') {
|
||||||
|
const today = new Date().toISOString().split('T')[0]
|
||||||
|
return { start: today, end: today }
|
||||||
|
}
|
||||||
|
if (settings?.type === '7') return getDateRange(7)
|
||||||
|
if (settings?.type === 'custom' && settings.dateRange) return settings.dateRange
|
||||||
|
return getDateRange(30)
|
||||||
|
}
|
||||||
|
|
||||||
export default function SiteDashboardPage() {
|
export default function SiteDashboardPage() {
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
@@ -31,69 +68,75 @@ export default function SiteDashboardPage() {
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const siteId = params.id as string
|
const siteId = params.id as string
|
||||||
|
|
||||||
const [site, setSite] = useState<Site | null>(null)
|
// UI state - initialized from localStorage synchronously to avoid double-fetch
|
||||||
const [loading, setLoading] = useState(true)
|
const [dateRange, setDateRange] = useState(getInitialDateRange)
|
||||||
const [stats, setStats] = useState<Stats>({ pageviews: 0, visitors: 0, bounce_rate: 0, avg_duration: 0 })
|
const [todayInterval, setTodayInterval] = useState<'minute' | 'hour'>(
|
||||||
const [prevStats, setPrevStats] = useState<Stats | undefined>(undefined)
|
() => loadSavedSettings()?.todayInterval || 'hour'
|
||||||
const [realtime, setRealtime] = useState(0)
|
)
|
||||||
const [dailyStats, setDailyStats] = useState<DailyStat[]>([])
|
const [multiDayInterval, setMultiDayInterval] = useState<'hour' | 'day'>(
|
||||||
const [prevDailyStats, setPrevDailyStats] = useState<DailyStat[] | undefined>(undefined)
|
() => loadSavedSettings()?.multiDayInterval || 'day'
|
||||||
const [topPages, setTopPages] = useState<any[]>([])
|
)
|
||||||
const [entryPages, setEntryPages] = useState<any[]>([])
|
|
||||||
const [exitPages, setExitPages] = useState<any[]>([])
|
|
||||||
const [topReferrers, setTopReferrers] = useState<any[]>([])
|
|
||||||
const [countries, setCountries] = useState<any[]>([])
|
|
||||||
const [cities, setCities] = useState<any[]>([])
|
|
||||||
const [regions, setRegions] = useState<any[]>([])
|
|
||||||
const [browsers, setBrowsers] = useState<any[]>([])
|
|
||||||
const [os, setOS] = useState<any[]>([])
|
|
||||||
const [devices, setDevices] = useState<any[]>([])
|
|
||||||
const [screenResolutions, setScreenResolutions] = useState<any[]>([])
|
|
||||||
const [performance, setPerformance] = useState<{ lcp: number, cls: number, inp: number }>({ lcp: 0, cls: 0, inp: 0 })
|
|
||||||
const [performanceByPage, setPerformanceByPage] = useState<PerformanceByPageStat[] | null>(null)
|
|
||||||
const [goalCounts, setGoalCounts] = useState<Array<{ event_name: string; count: number }>>([])
|
|
||||||
const [campaigns, setCampaigns] = useState<any[]>([])
|
|
||||||
const [dateRange, setDateRange] = useState(getDateRange(30))
|
|
||||||
const [isDatePickerOpen, setIsDatePickerOpen] = useState(false)
|
const [isDatePickerOpen, setIsDatePickerOpen] = useState(false)
|
||||||
const [isExportModalOpen, setIsExportModalOpen] = useState(false)
|
const [isExportModalOpen, setIsExportModalOpen] = useState(false)
|
||||||
const [todayInterval, setTodayInterval] = useState<'minute' | 'hour'>('hour')
|
|
||||||
const [multiDayInterval, setMultiDayInterval] = useState<'hour' | 'day'>('day')
|
|
||||||
const [isSettingsLoaded, setIsSettingsLoaded] = useState(false)
|
|
||||||
const [lastUpdatedAt, setLastUpdatedAt] = useState<number | null>(null)
|
const [lastUpdatedAt, setLastUpdatedAt] = useState<number | null>(null)
|
||||||
const [, setTick] = useState(0)
|
const [, setTick] = useState(0)
|
||||||
|
|
||||||
// Load settings from localStorage
|
const interval = dateRange.start === dateRange.end ? todayInterval : multiDayInterval
|
||||||
useEffect(() => {
|
|
||||||
try {
|
|
||||||
const savedSettings = localStorage.getItem('pulse_dashboard_settings')
|
|
||||||
if (savedSettings) {
|
|
||||||
const settings = JSON.parse(savedSettings)
|
|
||||||
|
|
||||||
// Restore date range
|
|
||||||
if (settings.type === 'today') {
|
|
||||||
const today = new Date().toISOString().split('T')[0]
|
|
||||||
setDateRange({ start: today, end: today })
|
|
||||||
} else if (settings.type === '7') {
|
|
||||||
setDateRange(getDateRange(7))
|
|
||||||
} else if (settings.type === '30') {
|
|
||||||
setDateRange(getDateRange(30))
|
|
||||||
} else if (settings.type === 'custom' && settings.dateRange) {
|
|
||||||
setDateRange(settings.dateRange)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Restore intervals
|
// Previous period date range for comparison
|
||||||
if (settings.todayInterval) setTodayInterval(settings.todayInterval)
|
const prevRange = useMemo(() => {
|
||||||
if (settings.multiDayInterval) setMultiDayInterval(settings.multiDayInterval)
|
const startDate = new Date(dateRange.start)
|
||||||
}
|
const endDate = new Date(dateRange.end)
|
||||||
} catch (e) {
|
const duration = endDate.getTime() - startDate.getTime()
|
||||||
logger.error('Failed to load dashboard settings', e)
|
if (duration === 0) {
|
||||||
} finally {
|
const prevEnd = new Date(startDate.getTime() - 24 * 60 * 60 * 1000)
|
||||||
setIsSettingsLoaded(true)
|
return { start: prevEnd.toISOString().split('T')[0], end: prevEnd.toISOString().split('T')[0] }
|
||||||
}
|
}
|
||||||
|
const prevEnd = new Date(startDate.getTime() - 24 * 60 * 60 * 1000)
|
||||||
|
const prevStart = new Date(prevEnd.getTime() - duration)
|
||||||
|
return { start: prevStart.toISOString().split('T')[0], end: prevEnd.toISOString().split('T')[0] }
|
||||||
|
}, [dateRange])
|
||||||
|
|
||||||
|
// SWR hooks - replace manual useState + useEffect + setInterval polling
|
||||||
|
// Each hook handles its own refresh interval, deduplication, and error retry
|
||||||
|
const { data: overview, isLoading: overviewLoading, error: overviewError } = useDashboardOverview(siteId, dateRange.start, dateRange.end, interval)
|
||||||
|
const { data: pages } = useDashboardPages(siteId, dateRange.start, dateRange.end)
|
||||||
|
const { data: locations } = useDashboardLocations(siteId, dateRange.start, dateRange.end)
|
||||||
|
const { data: devicesData } = useDashboardDevices(siteId, dateRange.start, dateRange.end)
|
||||||
|
const { data: referrers } = useDashboardReferrers(siteId, dateRange.start, dateRange.end)
|
||||||
|
const { data: performanceData } = useDashboardPerformance(siteId, dateRange.start, dateRange.end)
|
||||||
|
const { data: goalsData } = useDashboardGoals(siteId, dateRange.start, dateRange.end)
|
||||||
|
const { data: realtimeData } = useRealtime(siteId)
|
||||||
|
const { data: prevStats } = useStats(siteId, prevRange.start, prevRange.end)
|
||||||
|
const { data: prevDailyStats } = useDailyStats(siteId, prevRange.start, prevRange.end, interval)
|
||||||
|
const { data: campaigns } = useCampaigns(siteId, dateRange.start, dateRange.end)
|
||||||
|
|
||||||
|
// Derive typed values from SWR data
|
||||||
|
const site = overview?.site ?? null
|
||||||
|
const stats: Stats = overview?.stats ?? { pageviews: 0, visitors: 0, bounce_rate: 0, avg_duration: 0 }
|
||||||
|
const realtime = realtimeData?.visitors ?? overview?.realtime_visitors ?? 0
|
||||||
|
const dailyStats: DailyStat[] = overview?.daily_stats ?? []
|
||||||
|
|
||||||
|
// Show error toast on fetch failure
|
||||||
|
useEffect(() => {
|
||||||
|
if (overviewError) {
|
||||||
|
toast.error('Failed to load dashboard analytics')
|
||||||
|
}
|
||||||
|
}, [overviewError])
|
||||||
|
|
||||||
|
// Track when data was last updated (for "Live · Xs ago" display)
|
||||||
|
useEffect(() => {
|
||||||
|
if (overview) setLastUpdatedAt(Date.now())
|
||||||
|
}, [overview])
|
||||||
|
|
||||||
|
// Tick every 1s so "Live · Xs ago" counts in real time
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setInterval(() => setTick((t) => t + 1), 1000)
|
||||||
|
return () => clearInterval(timer)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Save settings to localStorage
|
// Save settings to localStorage
|
||||||
const saveSettings = (type: string, newDateRange?: { start: string, end: string }) => {
|
const saveSettings = (type: string, newDateRange?: { start: string; end: string }) => {
|
||||||
try {
|
try {
|
||||||
const settings = {
|
const settings = {
|
||||||
type,
|
type,
|
||||||
@@ -110,9 +153,6 @@ export default function SiteDashboardPage() {
|
|||||||
|
|
||||||
// Save intervals when they change
|
// Save intervals when they change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isSettingsLoaded) return
|
|
||||||
|
|
||||||
// Determine current type
|
|
||||||
let type = 'custom'
|
let type = 'custom'
|
||||||
const today = new Date().toISOString().split('T')[0]
|
const today = new Date().toISOString().split('T')[0]
|
||||||
if (dateRange.start === today && dateRange.end === today) type = 'today'
|
if (dateRange.start === today && dateRange.end === today) type = 'today'
|
||||||
@@ -127,160 +167,13 @@ export default function SiteDashboardPage() {
|
|||||||
lastUpdated: Date.now()
|
lastUpdated: Date.now()
|
||||||
}
|
}
|
||||||
localStorage.setItem('pulse_dashboard_settings', JSON.stringify(settings))
|
localStorage.setItem('pulse_dashboard_settings', JSON.stringify(settings))
|
||||||
}, [todayInterval, multiDayInterval, isSettingsLoaded]) // dateRange is handled in saveSettings/onChange
|
}, [todayInterval, multiDayInterval]) // eslint-disable-line react-hooks/exhaustive-deps -- dateRange saved via saveSettings
|
||||||
|
|
||||||
// * Tick every 1s so "Live · Xs ago" counts in real time
|
|
||||||
useEffect(() => {
|
|
||||||
const interval = setInterval(() => setTick((t) => t + 1), 1000)
|
|
||||||
return () => clearInterval(interval)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const getPreviousDateRange = useCallback((start: string, end: string) => {
|
|
||||||
const startDate = new Date(start)
|
|
||||||
const endDate = new Date(end)
|
|
||||||
const duration = endDate.getTime() - startDate.getTime()
|
|
||||||
if (duration === 0) {
|
|
||||||
const prevEnd = new Date(startDate.getTime() - 24 * 60 * 60 * 1000)
|
|
||||||
return { start: prevEnd.toISOString().split('T')[0], end: prevEnd.toISOString().split('T')[0] }
|
|
||||||
}
|
|
||||||
const prevEnd = new Date(startDate.getTime() - 24 * 60 * 60 * 1000)
|
|
||||||
const prevStart = new Date(prevEnd.getTime() - duration)
|
|
||||||
return { start: prevStart.toISOString().split('T')[0], end: prevEnd.toISOString().split('T')[0] }
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// * Visibility-aware polling intervals
|
|
||||||
// * Historical data: 60s when visible, paused when hidden
|
|
||||||
// * Real-time data: 5s when visible, 30s when hidden
|
|
||||||
const [isVisible, setIsVisible] = useState(true)
|
|
||||||
const dashboardIntervalRef = useRef<NodeJS.Timeout | null>(null)
|
|
||||||
const realtimeIntervalRef = useRef<NodeJS.Timeout | null>(null)
|
|
||||||
|
|
||||||
// * Track visibility state
|
|
||||||
useEffect(() => {
|
|
||||||
const handleVisibilityChange = () => {
|
|
||||||
const visible = document.visibilityState === 'visible'
|
|
||||||
setIsVisible(visible)
|
|
||||||
}
|
|
||||||
document.addEventListener('visibilitychange', handleVisibilityChange)
|
|
||||||
return () => document.removeEventListener('visibilitychange', handleVisibilityChange)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const loadData = useCallback(async (silent = false) => {
|
|
||||||
try {
|
|
||||||
if (!silent) setLoading(true)
|
|
||||||
const interval = dateRange.start === dateRange.end ? todayInterval : multiDayInterval
|
|
||||||
|
|
||||||
const [data, prevStatsData, prevDailyStatsData, campaignsData] = await Promise.all([
|
|
||||||
getDashboard(siteId, dateRange.start, dateRange.end, 10, interval),
|
|
||||||
(async () => {
|
|
||||||
const prevRange = getPreviousDateRange(dateRange.start, dateRange.end)
|
|
||||||
return getStats(siteId, prevRange.start, prevRange.end)
|
|
||||||
})(),
|
|
||||||
(async () => {
|
|
||||||
const prevRange = getPreviousDateRange(dateRange.start, dateRange.end)
|
|
||||||
return getDailyStats(siteId, prevRange.start, prevRange.end, interval)
|
|
||||||
})(),
|
|
||||||
getCampaigns(siteId, dateRange.start, dateRange.end, 100),
|
|
||||||
])
|
|
||||||
|
|
||||||
setSite(data.site)
|
|
||||||
setStats(data.stats || { pageviews: 0, visitors: 0, bounce_rate: 0, avg_duration: 0 })
|
|
||||||
setRealtime(data.realtime_visitors || 0)
|
|
||||||
setDailyStats(Array.isArray(data.daily_stats) ? data.daily_stats : [])
|
|
||||||
|
|
||||||
setPrevStats(prevStatsData)
|
|
||||||
setPrevDailyStats(prevDailyStatsData)
|
|
||||||
|
|
||||||
setTopPages(Array.isArray(data.top_pages) ? data.top_pages : [])
|
|
||||||
setEntryPages(Array.isArray(data.entry_pages) ? data.entry_pages : [])
|
|
||||||
setExitPages(Array.isArray(data.exit_pages) ? data.exit_pages : [])
|
|
||||||
setTopReferrers(Array.isArray(data.top_referrers) ? data.top_referrers : [])
|
|
||||||
setCountries(Array.isArray(data.countries) ? data.countries : [])
|
|
||||||
setCities(Array.isArray(data.cities) ? data.cities : [])
|
|
||||||
setRegions(Array.isArray(data.regions) ? data.regions : [])
|
|
||||||
setBrowsers(Array.isArray(data.browsers) ? data.browsers : [])
|
|
||||||
setOS(Array.isArray(data.os) ? data.os : [])
|
|
||||||
setDevices(Array.isArray(data.devices) ? data.devices : [])
|
|
||||||
setScreenResolutions(Array.isArray(data.screen_resolutions) ? data.screen_resolutions : [])
|
|
||||||
setPerformance(data.performance || { lcp: 0, cls: 0, inp: 0 })
|
|
||||||
setPerformanceByPage(data.performance_by_page ?? null)
|
|
||||||
setGoalCounts(Array.isArray(data.goal_counts) ? data.goal_counts : [])
|
|
||||||
setCampaigns(Array.isArray(campaignsData) ? campaignsData : [])
|
|
||||||
setLastUpdatedAt(Date.now())
|
|
||||||
} catch (error: unknown) {
|
|
||||||
if (!silent) {
|
|
||||||
toast.error(getAuthErrorMessage(error) || 'Failed to load dashboard analytics')
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (!silent) setLoading(false)
|
|
||||||
}
|
|
||||||
}, [siteId, dateRange, todayInterval, multiDayInterval])
|
|
||||||
|
|
||||||
const loadRealtime = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const data = await getRealtime(siteId)
|
|
||||||
setRealtime(data.visitors)
|
|
||||||
} catch (error) {
|
|
||||||
// * Silently fail for realtime updates
|
|
||||||
}
|
|
||||||
}, [siteId])
|
|
||||||
|
|
||||||
// * Visibility-aware polling for dashboard data (historical)
|
|
||||||
// * Refreshes every 60 seconds when tab is visible, pauses when hidden
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isSettingsLoaded) return
|
|
||||||
|
|
||||||
// * Initial load
|
|
||||||
loadData()
|
|
||||||
|
|
||||||
// * Clear existing interval
|
|
||||||
if (dashboardIntervalRef.current) {
|
|
||||||
clearInterval(dashboardIntervalRef.current)
|
|
||||||
}
|
|
||||||
|
|
||||||
// * Only poll when visible (saves server resources when tab is backgrounded)
|
|
||||||
if (isVisible) {
|
|
||||||
dashboardIntervalRef.current = setInterval(() => {
|
|
||||||
loadData(true)
|
|
||||||
}, 60000) // * 60 seconds for historical data
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (dashboardIntervalRef.current) {
|
|
||||||
clearInterval(dashboardIntervalRef.current)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [siteId, dateRange, todayInterval, multiDayInterval, isSettingsLoaded, loadData, isVisible])
|
|
||||||
|
|
||||||
// * Visibility-aware polling for realtime data
|
|
||||||
// * Refreshes every 5 seconds when visible, every 30 seconds when hidden
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isSettingsLoaded) return
|
|
||||||
|
|
||||||
// * Clear existing interval
|
|
||||||
if (realtimeIntervalRef.current) {
|
|
||||||
clearInterval(realtimeIntervalRef.current)
|
|
||||||
}
|
|
||||||
|
|
||||||
// * Different intervals based on visibility
|
|
||||||
const interval = isVisible ? 5000 : 30000 // * 5s visible, 30s hidden
|
|
||||||
|
|
||||||
realtimeIntervalRef.current = setInterval(() => {
|
|
||||||
loadRealtime()
|
|
||||||
}, interval)
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (realtimeIntervalRef.current) {
|
|
||||||
clearInterval(realtimeIntervalRef.current)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [siteId, isSettingsLoaded, loadRealtime, isVisible])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (site?.domain) document.title = `${site.domain} | Pulse`
|
if (site?.domain) document.title = `${site.domain} | Pulse`
|
||||||
}, [site?.domain])
|
}, [site?.domain])
|
||||||
|
|
||||||
const showSkeleton = useMinimumLoading(loading)
|
const showSkeleton = useMinimumLoading(overviewLoading)
|
||||||
|
|
||||||
if (showSkeleton) {
|
if (showSkeleton) {
|
||||||
return <DashboardSkeleton />
|
return <DashboardSkeleton />
|
||||||
@@ -312,7 +205,7 @@ export default function SiteDashboardPage() {
|
|||||||
{site.domain}
|
{site.domain}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Realtime Indicator */}
|
{/* Realtime Indicator */}
|
||||||
<button
|
<button
|
||||||
onClick={() => router.push(`/sites/${siteId}/realtime`)}
|
onClick={() => router.push(`/sites/${siteId}/realtime`)}
|
||||||
@@ -414,10 +307,10 @@ export default function SiteDashboardPage() {
|
|||||||
|
|
||||||
{/* Advanced Chart with Integrated Stats */}
|
{/* Advanced Chart with Integrated Stats */}
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<Chart
|
<Chart
|
||||||
data={dailyStats}
|
data={dailyStats}
|
||||||
prevData={prevDailyStats}
|
prevData={prevDailyStats}
|
||||||
stats={stats}
|
stats={stats}
|
||||||
prevStats={prevStats}
|
prevStats={prevStats}
|
||||||
interval={dateRange.start === dateRange.end ? todayInterval : multiDayInterval}
|
interval={dateRange.start === dateRange.end ? todayInterval : multiDayInterval}
|
||||||
dateRange={dateRange}
|
dateRange={dateRange}
|
||||||
@@ -433,8 +326,8 @@ export default function SiteDashboardPage() {
|
|||||||
{site.enable_performance_insights && (
|
{site.enable_performance_insights && (
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<PerformanceStats
|
<PerformanceStats
|
||||||
stats={performance}
|
stats={performanceData?.performance ?? { lcp: 0, cls: 0, inp: 0 }}
|
||||||
performanceByPage={performanceByPage}
|
performanceByPage={performanceData?.performance_by_page ?? null}
|
||||||
siteId={siteId}
|
siteId={siteId}
|
||||||
startDate={dateRange.start}
|
startDate={dateRange.start}
|
||||||
endDate={dateRange.end}
|
endDate={dateRange.end}
|
||||||
@@ -445,16 +338,16 @@ export default function SiteDashboardPage() {
|
|||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-2 mb-8">
|
<div className="grid gap-6 lg:grid-cols-2 mb-8">
|
||||||
<ContentStats
|
<ContentStats
|
||||||
topPages={topPages}
|
topPages={pages?.top_pages ?? []}
|
||||||
entryPages={entryPages}
|
entryPages={pages?.entry_pages ?? []}
|
||||||
exitPages={exitPages}
|
exitPages={pages?.exit_pages ?? []}
|
||||||
domain={site.domain}
|
domain={site.domain}
|
||||||
collectPagePaths={site.collect_page_paths ?? true}
|
collectPagePaths={site.collect_page_paths ?? true}
|
||||||
siteId={siteId}
|
siteId={siteId}
|
||||||
dateRange={dateRange}
|
dateRange={dateRange}
|
||||||
/>
|
/>
|
||||||
<TopReferrers
|
<TopReferrers
|
||||||
referrers={topReferrers}
|
referrers={referrers?.top_referrers ?? []}
|
||||||
collectReferrers={site.collect_referrers ?? true}
|
collectReferrers={site.collect_referrers ?? true}
|
||||||
siteId={siteId}
|
siteId={siteId}
|
||||||
dateRange={dateRange}
|
dateRange={dateRange}
|
||||||
@@ -463,18 +356,18 @@ export default function SiteDashboardPage() {
|
|||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-2 mb-8">
|
<div className="grid gap-6 lg:grid-cols-2 mb-8">
|
||||||
<Locations
|
<Locations
|
||||||
countries={countries}
|
countries={locations?.countries ?? []}
|
||||||
cities={cities}
|
cities={locations?.cities ?? []}
|
||||||
regions={regions}
|
regions={locations?.regions ?? []}
|
||||||
geoDataLevel={site.collect_geo_data || 'full'}
|
geoDataLevel={site.collect_geo_data || 'full'}
|
||||||
siteId={siteId}
|
siteId={siteId}
|
||||||
dateRange={dateRange}
|
dateRange={dateRange}
|
||||||
/>
|
/>
|
||||||
<TechSpecs
|
<TechSpecs
|
||||||
browsers={browsers}
|
browsers={devicesData?.browsers ?? []}
|
||||||
os={os}
|
os={devicesData?.os ?? []}
|
||||||
devices={devices}
|
devices={devicesData?.devices ?? []}
|
||||||
screenResolutions={screenResolutions}
|
screenResolutions={devicesData?.screen_resolutions ?? []}
|
||||||
collectDeviceInfo={site.collect_device_info ?? true}
|
collectDeviceInfo={site.collect_device_info ?? true}
|
||||||
collectScreenResolution={site.collect_screen_resolution ?? true}
|
collectScreenResolution={site.collect_screen_resolution ?? true}
|
||||||
siteId={siteId}
|
siteId={siteId}
|
||||||
@@ -488,7 +381,7 @@ export default function SiteDashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<GoalStats goalCounts={goalCounts} />
|
<GoalStats goalCounts={goalsData?.goal_counts ?? []} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DatePicker
|
<DatePicker
|
||||||
@@ -507,8 +400,8 @@ export default function SiteDashboardPage() {
|
|||||||
onClose={() => setIsExportModalOpen(false)}
|
onClose={() => setIsExportModalOpen(false)}
|
||||||
data={dailyStats}
|
data={dailyStats}
|
||||||
stats={stats}
|
stats={stats}
|
||||||
topPages={topPages}
|
topPages={pages?.top_pages}
|
||||||
topReferrers={topReferrers}
|
topReferrers={referrers?.top_referrers}
|
||||||
campaigns={campaigns}
|
campaigns={campaigns}
|
||||||
/>
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
getDashboardReferrers,
|
getDashboardReferrers,
|
||||||
getDashboardPerformance,
|
getDashboardPerformance,
|
||||||
getDashboardGoals,
|
getDashboardGoals,
|
||||||
|
getCampaigns,
|
||||||
getRealtime,
|
getRealtime,
|
||||||
getStats,
|
getStats,
|
||||||
getDailyStats,
|
getDailyStats,
|
||||||
@@ -20,6 +21,7 @@ import type { Site } from '@/lib/api/sites'
|
|||||||
import type {
|
import type {
|
||||||
Stats,
|
Stats,
|
||||||
DailyStat,
|
DailyStat,
|
||||||
|
CampaignStat,
|
||||||
DashboardOverviewData,
|
DashboardOverviewData,
|
||||||
DashboardPagesData,
|
DashboardPagesData,
|
||||||
DashboardLocationsData,
|
DashboardLocationsData,
|
||||||
@@ -33,7 +35,7 @@ import type {
|
|||||||
const fetchers = {
|
const fetchers = {
|
||||||
site: (siteId: string) => getSite(siteId),
|
site: (siteId: string) => getSite(siteId),
|
||||||
dashboard: (siteId: string, start: string, end: string) => getDashboard(siteId, start, end),
|
dashboard: (siteId: string, start: string, end: string) => getDashboard(siteId, start, end),
|
||||||
dashboardOverview: (siteId: string, start: string, end: string) => getDashboardOverview(siteId, start, end),
|
dashboardOverview: (siteId: string, start: string, end: string, interval?: string) => getDashboardOverview(siteId, start, end, interval),
|
||||||
dashboardPages: (siteId: string, start: string, end: string) => getDashboardPages(siteId, start, end),
|
dashboardPages: (siteId: string, start: string, end: string) => getDashboardPages(siteId, start, end),
|
||||||
dashboardLocations: (siteId: string, start: string, end: string) => getDashboardLocations(siteId, start, end),
|
dashboardLocations: (siteId: string, start: string, end: string) => getDashboardLocations(siteId, start, end),
|
||||||
dashboardDevices: (siteId: string, start: string, end: string) => getDashboardDevices(siteId, start, end),
|
dashboardDevices: (siteId: string, start: string, end: string) => getDashboardDevices(siteId, start, end),
|
||||||
@@ -44,6 +46,8 @@ const fetchers = {
|
|||||||
dailyStats: (siteId: string, start: string, end: string, interval: 'hour' | 'day' | 'minute') =>
|
dailyStats: (siteId: string, start: string, end: string, interval: 'hour' | 'day' | 'minute') =>
|
||||||
getDailyStats(siteId, start, end, interval),
|
getDailyStats(siteId, start, end, interval),
|
||||||
realtime: (siteId: string) => getRealtime(siteId),
|
realtime: (siteId: string) => getRealtime(siteId),
|
||||||
|
campaigns: (siteId: string, start: string, end: string, limit: number) =>
|
||||||
|
getCampaigns(siteId, start, end, limit),
|
||||||
}
|
}
|
||||||
|
|
||||||
// * Standard SWR config for dashboard data
|
// * Standard SWR config for dashboard data
|
||||||
@@ -140,10 +144,10 @@ export function useRealtime(siteId: string, refreshInterval: number = 5000) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// * Hook for focused dashboard overview data (Fix 4.2: Efficient Data Transfer)
|
// * Hook for focused dashboard overview data (Fix 4.2: Efficient Data Transfer)
|
||||||
export function useDashboardOverview(siteId: string, start: string, end: string) {
|
export function useDashboardOverview(siteId: string, start: string, end: string, interval?: string) {
|
||||||
return useSWR<DashboardOverviewData>(
|
return useSWR<DashboardOverviewData>(
|
||||||
siteId && start && end ? ['dashboardOverview', siteId, start, end] : null,
|
siteId && start && end ? ['dashboardOverview', siteId, start, end, interval] : null,
|
||||||
() => fetchers.dashboardOverview(siteId, start, end),
|
() => fetchers.dashboardOverview(siteId, start, end, interval),
|
||||||
{
|
{
|
||||||
...dashboardSWRConfig,
|
...dashboardSWRConfig,
|
||||||
refreshInterval: 60 * 1000,
|
refreshInterval: 60 * 1000,
|
||||||
@@ -230,5 +234,18 @@ export function useDashboardGoals(siteId: string, start: string, end: string) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// * Hook for campaigns data (used by export modal)
|
||||||
|
export function useCampaigns(siteId: string, start: string, end: string, limit = 100) {
|
||||||
|
return useSWR<CampaignStat[]>(
|
||||||
|
siteId && start && end ? ['campaigns', siteId, start, end, limit] : null,
|
||||||
|
() => fetchers.campaigns(siteId, start, end, limit),
|
||||||
|
{
|
||||||
|
...dashboardSWRConfig,
|
||||||
|
refreshInterval: 60 * 1000,
|
||||||
|
dedupingInterval: 10 * 1000,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// * Re-export for convenience
|
// * Re-export for convenience
|
||||||
export { fetchers }
|
export { fetchers }
|
||||||
|
|||||||
Reference in New Issue
Block a user