From 8a7076ee1b53e76fde8df3c91d291ee273b2dfb6 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sun, 1 Mar 2026 18:42:14 +0100 Subject: [PATCH] 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 --- CHANGELOG.md | 5 + app/layout-content.tsx | 4 +- app/sites/[id]/page.tsx | 355 ++++++++++++++-------------------------- lib/swr/dashboard.ts | 25 ++- 4 files changed, 152 insertions(+), 237 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cde1b25..cfd9f9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [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 - **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. diff --git a/app/layout-content.tsx b/app/layout-content.tsx index be8772c..8ef4acd 100644 --- a/app/layout-content.tsx +++ b/app/layout-content.tsx @@ -9,7 +9,7 @@ import { useOnlineStatus } from '@/lib/hooks/useOnlineStatus' import Link from 'next/link' import { useEffect, useState } from 'react' 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 { LoadingOverlay } from '@ciphera-net/ui' import { useRouter } from 'next/navigation' @@ -48,7 +48,7 @@ export default function LayoutContent({ children }: { children: React.ReactNode const auth = useAuth() const router = useRouter() const isOnline = useOnlineStatus() - const [orgs, setOrgs] = useState([]) + const [orgs, setOrgs] = useState([]) const [isSwitchingOrg, setIsSwitchingOrg] = useState(() => { if (typeof window === 'undefined') return false return sessionStorage.getItem(ORG_SWITCH_KEY) === 'true' diff --git a/app/sites/[id]/page.tsx b/app/sites/[id]/page.tsx index 56f73fd..31ace64 100644 --- a/app/sites/[id]/page.tsx +++ b/app/sites/[id]/page.tsx @@ -2,15 +2,13 @@ import { useAuth } from '@/lib/auth/context' 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 { motion } from 'framer-motion' -import { getSite, type Site } from '@/lib/api/sites' -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 { formatNumber, formatDuration, getDateRange } from '@ciphera-net/ui' +import { getPerformanceByPage, type Stats, type DailyStat } from '@/lib/api/stats' +import { getDateRange } from '@ciphera-net/ui' import { toast } from '@ciphera-net/ui' -import { getAuthErrorMessage } from '@ciphera-net/ui' -import { LoadingOverlay, Button } from '@ciphera-net/ui' +import { Button } from '@ciphera-net/ui' import { Select, DatePicker, DownloadIcon } from '@ciphera-net/ui' import { DashboardSkeleton, useMinimumLoading } from '@/components/skeletons' import ExportModal from '@/components/dashboard/ExportModal' @@ -22,6 +20,45 @@ import Chart from '@/components/dashboard/Chart' import PerformanceStats from '@/components/dashboard/PerformanceStats' import GoalStats from '@/components/dashboard/GoalStats' 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() { const { user } = useAuth() @@ -31,69 +68,75 @@ export default function SiteDashboardPage() { const router = useRouter() const siteId = params.id as string - const [site, setSite] = useState(null) - const [loading, setLoading] = useState(true) - const [stats, setStats] = useState({ pageviews: 0, visitors: 0, bounce_rate: 0, avg_duration: 0 }) - const [prevStats, setPrevStats] = useState(undefined) - const [realtime, setRealtime] = useState(0) - const [dailyStats, setDailyStats] = useState([]) - const [prevDailyStats, setPrevDailyStats] = useState(undefined) - const [topPages, setTopPages] = useState([]) - const [entryPages, setEntryPages] = useState([]) - const [exitPages, setExitPages] = useState([]) - const [topReferrers, setTopReferrers] = useState([]) - const [countries, setCountries] = useState([]) - const [cities, setCities] = useState([]) - const [regions, setRegions] = useState([]) - const [browsers, setBrowsers] = useState([]) - const [os, setOS] = useState([]) - const [devices, setDevices] = useState([]) - const [screenResolutions, setScreenResolutions] = useState([]) - const [performance, setPerformance] = useState<{ lcp: number, cls: number, inp: number }>({ lcp: 0, cls: 0, inp: 0 }) - const [performanceByPage, setPerformanceByPage] = useState(null) - const [goalCounts, setGoalCounts] = useState>([]) - const [campaigns, setCampaigns] = useState([]) - const [dateRange, setDateRange] = useState(getDateRange(30)) + // UI state - initialized from localStorage synchronously to avoid double-fetch + const [dateRange, setDateRange] = useState(getInitialDateRange) + const [todayInterval, setTodayInterval] = useState<'minute' | 'hour'>( + () => loadSavedSettings()?.todayInterval || 'hour' + ) + const [multiDayInterval, setMultiDayInterval] = useState<'hour' | 'day'>( + () => loadSavedSettings()?.multiDayInterval || 'day' + ) const [isDatePickerOpen, setIsDatePickerOpen] = 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(null) const [, setTick] = useState(0) - // Load settings from localStorage - 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) - } + const interval = dateRange.start === dateRange.end ? todayInterval : multiDayInterval - // Restore intervals - if (settings.todayInterval) setTodayInterval(settings.todayInterval) - if (settings.multiDayInterval) setMultiDayInterval(settings.multiDayInterval) - } - } catch (e) { - logger.error('Failed to load dashboard settings', e) - } finally { - setIsSettingsLoaded(true) + // Previous period date range for comparison + const prevRange = useMemo(() => { + const startDate = new Date(dateRange.start) + const endDate = new Date(dateRange.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] } + }, [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 - const saveSettings = (type: string, newDateRange?: { start: string, end: string }) => { + const saveSettings = (type: string, newDateRange?: { start: string; end: string }) => { try { const settings = { type, @@ -110,9 +153,6 @@ export default function SiteDashboardPage() { // Save intervals when they change useEffect(() => { - if (!isSettingsLoaded) return - - // Determine current type let type = 'custom' const today = new Date().toISOString().split('T')[0] if (dateRange.start === today && dateRange.end === today) type = 'today' @@ -127,160 +167,13 @@ export default function SiteDashboardPage() { lastUpdated: Date.now() } localStorage.setItem('pulse_dashboard_settings', JSON.stringify(settings)) - }, [todayInterval, multiDayInterval, isSettingsLoaded]) // dateRange is handled in saveSettings/onChange - - // * 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(null) - const realtimeIntervalRef = useRef(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]) + }, [todayInterval, multiDayInterval]) // eslint-disable-line react-hooks/exhaustive-deps -- dateRange saved via saveSettings useEffect(() => { if (site?.domain) document.title = `${site.domain} | Pulse` }, [site?.domain]) - const showSkeleton = useMinimumLoading(loading) + const showSkeleton = useMinimumLoading(overviewLoading) if (showSkeleton) { return @@ -312,7 +205,7 @@ export default function SiteDashboardPage() { {site.domain}

- + {/* Realtime Indicator */}