chore: update CHANGELOG.md to include enhancements for dashboard performance, including smarter updates, real-time visitor tracking, and faster event processing
This commit is contained in:
@@ -8,6 +8,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
|
||||
### Added
|
||||
|
||||
- **Smarter dashboard updates.** Your dashboard now knows when you're actively viewing it versus when it's in the background. When you switch to another tab, we intelligently slow down data refreshes to save resources, then instantly catch up when you return. This keeps your analytics current without putting unnecessary load on the system.
|
||||
- **Instant real-time visitor counts.** Your dashboard's "current visitors" counter now updates lightning-fast using an optimized tracking system. Instead of scanning your entire database, we maintain a live session index that shows active visitors in milliseconds—even when thousands of people are browsing your sites simultaneously.
|
||||
- **More accurate visitor tracking.** We've upgraded how we identify unique visitors to ensure your analytics are always precise, even during the busiest traffic spikes. Every visitor now gets a truly unique identifier that never overlaps with others, eliminating rare edge cases where visitor counts could be slightly off.
|
||||
- **Faster event tracking.** Your analytics data is now captured instantly without slowing down your website. We've switched to asynchronous processing that collects events in batches of 100, so your visitors' page views and interactions are recorded with zero impact on their browsing experience, even during traffic spikes.
|
||||
- **Faster dashboard loading.** Your site analytics now load almost instantly, even during busy periods. Behind the scenes, we've added intelligent caching that remembers your dashboard data for 30 seconds and refreshes it automatically in the background. Real-time visitor counts are updated every 5 seconds so you always see current activity without waiting.
|
||||
- **Better data management for long-term performance.** We've restructured how your analytics data is stored so the app stays fast even as you collect months of data. Old data is now automatically organized by month and cleaned up efficiently based on your retention settings, keeping everything running smoothly no matter how much traffic you get.
|
||||
- **Smarter database indexing.** We've optimized how your analytics data is indexed, making common queries—like loading your dashboard or filtering by date—significantly faster. This also reduces storage overhead, keeping the app lean as your data grows.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useAuth } from '@/lib/auth/context'
|
||||
import { logger } from '@/lib/utils/logger'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useState, useRef } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { motion } from 'framer-motion'
|
||||
import { getSite, type Site } from '@/lib/api/sites'
|
||||
@@ -148,6 +148,23 @@ export default function SiteDashboardPage() {
|
||||
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)
|
||||
@@ -204,18 +221,60 @@ export default function SiteDashboardPage() {
|
||||
const data = await getRealtime(siteId)
|
||||
setRealtime(data.visitors)
|
||||
} catch (error) {
|
||||
// Silently fail for realtime updates
|
||||
// * 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) loadData()
|
||||
const interval = setInterval(() => {
|
||||
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()
|
||||
}, 30000)
|
||||
return () => clearInterval(interval)
|
||||
}, [siteId, dateRange, todayInterval, multiDayInterval, isSettingsLoaded, loadData, loadRealtime])
|
||||
}, interval)
|
||||
|
||||
return () => {
|
||||
if (realtimeIntervalRef.current) {
|
||||
clearInterval(realtimeIntervalRef.current)
|
||||
}
|
||||
}
|
||||
}, [siteId, isSettingsLoaded, loadRealtime, isVisible])
|
||||
|
||||
useEffect(() => {
|
||||
if (site?.domain) document.title = `${site.domain} | Pulse`
|
||||
|
||||
128
lib/hooks/useVisibilityPolling.ts
Normal file
128
lib/hooks/useVisibilityPolling.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
// * Custom hook for visibility-aware polling
|
||||
// * Pauses polling when tab is not visible, resumes when visible
|
||||
// * Reduces server load when users aren't actively viewing the dashboard
|
||||
|
||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||
|
||||
interface UseVisibilityPollingOptions {
|
||||
// * Polling interval when tab is visible (in milliseconds)
|
||||
visibleInterval: number
|
||||
// * Polling interval when tab is hidden (in milliseconds, or null to pause)
|
||||
hiddenInterval: number | null
|
||||
}
|
||||
|
||||
interface UseVisibilityPollingReturn {
|
||||
// * Whether polling is currently active
|
||||
isPolling: boolean
|
||||
// * Time since last poll
|
||||
lastPollTime: number | null
|
||||
// * Force a poll immediately
|
||||
triggerPoll: () => void
|
||||
}
|
||||
|
||||
export function useVisibilityPolling(
|
||||
callback: () => void | Promise<void>,
|
||||
options: UseVisibilityPollingOptions
|
||||
): UseVisibilityPollingReturn {
|
||||
const { visibleInterval, hiddenInterval } = options
|
||||
const [isPolling, setIsPolling] = useState(false)
|
||||
const [lastPollTime, setLastPollTime] = useState<number | null>(null)
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const callbackRef = useRef(callback)
|
||||
|
||||
// * Keep callback reference up to date
|
||||
useEffect(() => {
|
||||
callbackRef.current = callback
|
||||
}, [callback])
|
||||
|
||||
// * Get current polling interval based on visibility
|
||||
const getInterval = useCallback((): number | null => {
|
||||
if (typeof document === 'undefined') return null
|
||||
|
||||
const isVisible = document.visibilityState === 'visible'
|
||||
if (isVisible) {
|
||||
return visibleInterval
|
||||
}
|
||||
return hiddenInterval
|
||||
}, [visibleInterval, hiddenInterval])
|
||||
|
||||
// * Start polling with current interval
|
||||
const startPolling = useCallback(() => {
|
||||
const interval = getInterval()
|
||||
if (interval === null) {
|
||||
setIsPolling(false)
|
||||
return
|
||||
}
|
||||
|
||||
setIsPolling(true)
|
||||
|
||||
// * Clear any existing interval
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
}
|
||||
|
||||
// * Set up new interval
|
||||
intervalRef.current = setInterval(() => {
|
||||
callbackRef.current()
|
||||
setLastPollTime(Date.now())
|
||||
}, interval)
|
||||
}, [getInterval])
|
||||
|
||||
// * Stop polling
|
||||
const stopPolling = useCallback(() => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
intervalRef.current = null
|
||||
}
|
||||
setIsPolling(false)
|
||||
}, [])
|
||||
|
||||
// * Trigger immediate poll
|
||||
const triggerPoll = useCallback(() => {
|
||||
callbackRef.current()
|
||||
setLastPollTime(Date.now())
|
||||
|
||||
// * Restart polling timer
|
||||
startPolling()
|
||||
}, [startPolling])
|
||||
|
||||
// * Handle visibility changes
|
||||
useEffect(() => {
|
||||
if (typeof document === 'undefined') return
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
// * Tab became visible - resume polling with visible interval
|
||||
startPolling()
|
||||
// * Trigger immediate poll to get fresh data
|
||||
triggerPoll()
|
||||
} else {
|
||||
// * Tab hidden - switch to hidden interval or pause
|
||||
const interval = getInterval()
|
||||
if (interval === null) {
|
||||
stopPolling()
|
||||
} else {
|
||||
// * Restart with hidden interval
|
||||
startPolling()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// * Listen for visibility changes
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||
|
||||
// * Start polling initially
|
||||
startPolling()
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||
stopPolling()
|
||||
}
|
||||
}, [startPolling, stopPolling, triggerPoll, getInterval])
|
||||
|
||||
return {
|
||||
isPolling,
|
||||
lastPollTime,
|
||||
triggerPoll,
|
||||
}
|
||||
}
|
||||
114
lib/swr/dashboard.ts
Normal file
114
lib/swr/dashboard.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
// * SWR configuration for dashboard data fetching
|
||||
// * Implements stale-while-revalidate pattern for efficient data updates
|
||||
|
||||
import useSWR from 'swr'
|
||||
import { getDashboard, getRealtime, getStats, getDailyStats } from '@/lib/api/stats'
|
||||
import { getSite } from '@/lib/api/sites'
|
||||
import type { Site } from '@/lib/api/sites'
|
||||
import type { Stats, DailyStat } from '@/lib/api/stats'
|
||||
|
||||
// * SWR fetcher functions
|
||||
const fetchers = {
|
||||
site: (siteId: string) => getSite(siteId),
|
||||
dashboard: (siteId: string, start: string, end: string) => getDashboard(siteId, start, end),
|
||||
stats: (siteId: string, start: string, end: string) => getStats(siteId, start, end),
|
||||
dailyStats: (siteId: string, start: string, end: string, interval: 'hour' | 'day' | 'minute') =>
|
||||
getDailyStats(siteId, start, end, interval),
|
||||
realtime: (siteId: string) => getRealtime(siteId),
|
||||
}
|
||||
|
||||
// * Standard SWR config for dashboard data
|
||||
const dashboardSWRConfig = {
|
||||
// * Keep stale data visible while revalidating (better UX)
|
||||
revalidateOnFocus: false,
|
||||
// * Revalidate when reconnecting (fresh data after offline)
|
||||
revalidateOnReconnect: true,
|
||||
// * Retry failed requests
|
||||
shouldRetryOnError: true,
|
||||
errorRetryCount: 3,
|
||||
// * Error retry interval with exponential backoff
|
||||
errorRetryInterval: 5000,
|
||||
}
|
||||
|
||||
// * Hook for site data (loads once, refreshes rarely)
|
||||
export function useSite(siteId: string) {
|
||||
return useSWR<Site>(
|
||||
siteId ? ['site', siteId] : null,
|
||||
() => fetchers.site(siteId),
|
||||
{
|
||||
...dashboardSWRConfig,
|
||||
// * Site data changes rarely, refresh every 5 minutes
|
||||
refreshInterval: 5 * 60 * 1000,
|
||||
// * Deduping interval to prevent duplicate requests
|
||||
dedupingInterval: 30 * 1000,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// * Hook for dashboard summary data (refreshed less frequently)
|
||||
export function useDashboard(siteId: string, start: string, end: string) {
|
||||
return useSWR(
|
||||
siteId && start && end ? ['dashboard', siteId, start, end] : null,
|
||||
() => fetchers.dashboard(siteId, start, end),
|
||||
{
|
||||
...dashboardSWRConfig,
|
||||
// * Refresh every 60 seconds for dashboard summary
|
||||
refreshInterval: 60 * 1000,
|
||||
// * Deduping interval to prevent duplicate requests
|
||||
dedupingInterval: 10 * 1000,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// * Hook for stats (refreshed less frequently)
|
||||
export function useStats(siteId: string, start: string, end: string) {
|
||||
return useSWR<Stats>(
|
||||
siteId && start && end ? ['stats', siteId, start, end] : null,
|
||||
() => fetchers.stats(siteId, start, end),
|
||||
{
|
||||
...dashboardSWRConfig,
|
||||
// * Refresh every 60 seconds for stats
|
||||
refreshInterval: 60 * 1000,
|
||||
dedupingInterval: 10 * 1000,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// * Hook for daily stats (refreshed less frequently)
|
||||
export function useDailyStats(
|
||||
siteId: string,
|
||||
start: string,
|
||||
end: string,
|
||||
interval: 'hour' | 'day' | 'minute'
|
||||
) {
|
||||
return useSWR<DailyStat[]>(
|
||||
siteId && start && end ? ['dailyStats', siteId, start, end, interval] : null,
|
||||
() => fetchers.dailyStats(siteId, start, end, interval),
|
||||
{
|
||||
...dashboardSWRConfig,
|
||||
// * Refresh every 60 seconds for chart data
|
||||
refreshInterval: 60 * 1000,
|
||||
dedupingInterval: 10 * 1000,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// * Hook for realtime visitor count (refreshed frequently)
|
||||
export function useRealtime(siteId: string, refreshInterval: number = 5000) {
|
||||
return useSWR<{ visitors: number }>(
|
||||
siteId ? ['realtime', siteId] : null,
|
||||
() => fetchers.realtime(siteId),
|
||||
{
|
||||
...dashboardSWRConfig,
|
||||
// * Refresh frequently for real-time data (default 5 seconds)
|
||||
refreshInterval,
|
||||
// * Short deduping for real-time
|
||||
dedupingInterval: 2000,
|
||||
// * Keep previous data while loading new data
|
||||
keepPreviousData: true,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// * Re-export for convenience
|
||||
export { fetchers }
|
||||
@@ -33,6 +33,7 @@
|
||||
"react-simple-maps": "^3.0.0",
|
||||
"recharts": "^2.15.0",
|
||||
"sonner": "^2.0.7",
|
||||
"swr": "^2.3.3",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"overrides": {
|
||||
|
||||
Reference in New Issue
Block a user