'use client' import { logger } from '@/lib/utils/logger' import { useCallback, useEffect, useRef, useState, useMemo } from 'react' import { useParams, useRouter, useSearchParams } from 'next/navigation' import { getTopPages, getTopReferrers, getCountries, getCities, getRegions, getBrowsers, getOS, getDevices, getCampaigns, type Stats, type DailyStat, } from '@/lib/api/stats' import { getDateRange, formatDate, getThisWeekRange, getThisMonthRange } from '@/lib/utils/dateRanges' import { toast } from '@ciphera-net/ui' import { Button } from '@ciphera-net/ui' import { Select, DatePicker, DownloadIcon } from '@ciphera-net/ui' import dynamic from 'next/dynamic' import { DashboardSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons' import FilterBar from '@/components/dashboard/FilterBar' import AddFilterDropdown, { type FilterSuggestion, type FilterSuggestions } from '@/components/dashboard/AddFilterDropdown' import Chart from '@/components/dashboard/Chart' import ContentStats from '@/components/dashboard/ContentStats' import TopReferrers from '@/components/dashboard/TopReferrers' import Locations from '@/components/dashboard/Locations' import TechSpecs from '@/components/dashboard/TechSpecs' const GoalStats = dynamic(() => import('@/components/dashboard/GoalStats')) const Campaigns = dynamic(() => import('@/components/dashboard/Campaigns')) const PeakHours = dynamic(() => import('@/components/dashboard/PeakHours')) const SearchPerformance = dynamic(() => import('@/components/dashboard/SearchPerformance')) const EventProperties = dynamic(() => import('@/components/dashboard/EventProperties')) const ExportModal = dynamic(() => import('@/components/dashboard/ExportModal')) import { type DimensionFilter, serializeFilters, parseFiltersFromURL } from '@/lib/filters' import { useDashboard, useRealtime, useStats, useDailyStats, useCampaigns, useAnnotations, } from '@/lib/swr/dashboard' import { createAnnotation, updateAnnotation, deleteAnnotation, type AnnotationCategory } from '@/lib/api/annotations' 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 = formatDate(new Date()) return { start: today, end: today } } if (settings?.type === '7') return getDateRange(7) if (settings?.type === 'week') return getThisWeekRange() if (settings?.type === 'month') return getThisMonthRange() if (settings?.type === 'custom' && settings.dateRange) return settings.dateRange return getDateRange(30) } function getInitialPeriod(): string { return loadSavedSettings()?.type || '30' } export default function SiteDashboardPage() { const params = useParams() const router = useRouter() const siteId = params.id as string // UI state - initialized from localStorage synchronously to avoid double-fetch const [period, setPeriod] = useState(getInitialPeriod) 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 lastUpdatedAtRef = useRef(null) // Dimension filters state const searchParams = useSearchParams() const [filters, setFilters] = useState(() => { const raw = searchParams.get('filters') return raw ? parseFiltersFromURL(raw) : [] }) const filtersParam = useMemo(() => serializeFilters(filters), [filters]) // Selected event for property breakdown const [selectedEvent, setSelectedEvent] = useState(null) const handleAddFilter = useCallback((filter: DimensionFilter) => { setFilters(prev => { const isDuplicate = prev.some( f => f.dimension === filter.dimension && f.operator === filter.operator && f.values.join(';') === filter.values.join(';') ) if (isDuplicate) return prev return [...prev, filter] }) }, []) const handleRemoveFilter = useCallback((index: number) => { setFilters(prev => prev.filter((_, i) => i !== index)) }, []) const handleClearFilters = useCallback(() => { setFilters([]) }, []) // Fetch full suggestion list (up to 100) when a dimension is selected in the filter dropdown const handleFetchSuggestions = useCallback(async (dimension: string): Promise => { const start = dateRange.start const end = dateRange.end const f = filtersParam || undefined const limit = 100 try { const regionNames = (() => { try { return new Intl.DisplayNames(['en'], { type: 'region' }) } catch { return null } })() switch (dimension) { case 'page': { const data = await getTopPages(siteId, start, end, limit, f) return data.map(p => ({ value: p.path, label: p.path, count: p.pageviews })) } case 'referrer': { const data = await getTopReferrers(siteId, start, end, limit, f) return data.filter(r => r.referrer && r.referrer !== '').map(r => ({ value: r.referrer, label: r.referrer, count: r.pageviews })) } case 'country': { const data = await getCountries(siteId, start, end, limit, f) return data.filter(c => c.country && c.country !== 'Unknown').map(c => ({ value: c.country, label: regionNames?.of(c.country) ?? c.country, count: c.pageviews })) } case 'city': { const data = await getCities(siteId, start, end, limit, f) return data.filter(c => c.city && c.city !== 'Unknown').map(c => ({ value: c.city, label: c.city, count: c.pageviews })) } case 'region': { const data = await getRegions(siteId, start, end, limit, f) return data.filter(r => r.region && r.region !== 'Unknown').map(r => ({ value: r.region, label: r.region, count: r.pageviews })) } case 'browser': { const data = await getBrowsers(siteId, start, end, limit, f) return data.filter(b => b.browser && b.browser !== 'Unknown').map(b => ({ value: b.browser, label: b.browser, count: b.pageviews })) } case 'os': { const data = await getOS(siteId, start, end, limit, f) return data.filter(o => o.os && o.os !== 'Unknown').map(o => ({ value: o.os, label: o.os, count: o.pageviews })) } case 'device': { const data = await getDevices(siteId, start, end, limit, f) return data.filter(d => d.device && d.device !== 'Unknown').map(d => ({ value: d.device, label: d.device, count: d.pageviews })) } case 'utm_source': case 'utm_medium': case 'utm_campaign': { const data = await getCampaigns(siteId, start, end, limit, f) const map = new Map() const field = dimension === 'utm_source' ? 'source' : dimension === 'utm_medium' ? 'medium' : 'campaign' data.forEach(c => { const val = c[field] if (val) map.set(val, (map.get(val) ?? 0) + c.pageviews) }) return [...map.entries()].map(([v, count]) => ({ value: v, label: v, count })) } default: return [] } } catch { return [] } }, [siteId, dateRange.start, dateRange.end, filtersParam]) // Sync filters to URL useEffect(() => { const url = new URL(window.location.href) if (filtersParam) { url.searchParams.set('filters', filtersParam) } else { url.searchParams.delete('filters') } window.history.replaceState({}, '', url.toString()) }, [filtersParam]) const interval = dateRange.start === dateRange.end ? todayInterval : multiDayInterval // 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]) // Single dashboard request replaces focused hooks (overview, pages, locations, // devices, referrers, goals). The backend runs all queries in parallel // and caches the result in Redis for efficient data loading. const { data: dashboard, isLoading: dashboardLoading, isValidating: dashboardValidating, error: dashboardError } = useDashboard(siteId, dateRange.start, dateRange.end, interval, filtersParam || undefined) 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) const { data: annotations, mutate: mutateAnnotations } = useAnnotations(siteId, dateRange.start, dateRange.end) // Annotation mutation handlers const handleCreateAnnotation = async (data: { date: string; time?: string; text: string; category: string }) => { await createAnnotation(siteId, { ...data, category: data.category as AnnotationCategory }) mutateAnnotations() toast.success('Annotation added') } const handleUpdateAnnotation = async (id: string, data: { date: string; time?: string; text: string; category: string }) => { await updateAnnotation(siteId, id, { ...data, category: data.category as AnnotationCategory }) mutateAnnotations() toast.success('Annotation updated') } const handleDeleteAnnotation = async (id: string) => { await deleteAnnotation(siteId, id) mutateAnnotations() toast.success('Annotation deleted') } // Derive typed values from single dashboard response const site = dashboard?.site ?? null const stats: Stats = dashboard?.stats ?? { pageviews: 0, visitors: 0, bounce_rate: 0, avg_duration: 0 } const realtime = realtimeData?.visitors ?? dashboard?.realtime_visitors ?? 0 const dailyStats: DailyStat[] = dashboard?.daily_stats ?? [] // Build filter suggestions from current dashboard data const filterSuggestions = useMemo(() => { const s: FilterSuggestions = {} // Pages const topPages = dashboard?.top_pages ?? [] if (topPages.length > 0) { s.page = topPages.map(p => ({ value: p.path, label: p.path, count: p.pageviews })) } // Referrers const refs = dashboard?.top_referrers ?? [] if (refs.length > 0) { s.referrer = refs.filter(r => r.referrer && r.referrer !== '').map(r => ({ value: r.referrer, label: r.referrer, count: r.pageviews, })) } // Countries const ctrs = dashboard?.countries ?? [] if (ctrs.length > 0) { const regionNames = (() => { try { return new Intl.DisplayNames(['en'], { type: 'region' }) } catch { return null } })() s.country = ctrs.filter(c => c.country && c.country !== 'Unknown').map(c => ({ value: c.country, label: regionNames?.of(c.country) ?? c.country, count: c.pageviews, })) } // Regions const regs = dashboard?.regions ?? [] if (regs.length > 0) { s.region = regs.filter(r => r.region && r.region !== 'Unknown').map(r => ({ value: r.region, label: r.region, count: r.pageviews, })) } // Cities const cts = dashboard?.cities ?? [] if (cts.length > 0) { s.city = cts.filter(c => c.city && c.city !== 'Unknown').map(c => ({ value: c.city, label: c.city, count: c.pageviews, })) } // Browsers const brs = dashboard?.browsers ?? [] if (brs.length > 0) { s.browser = brs.filter(b => b.browser && b.browser !== 'Unknown').map(b => ({ value: b.browser, label: b.browser, count: b.pageviews, })) } // OS const oses = dashboard?.os ?? [] if (oses.length > 0) { s.os = oses.filter(o => o.os && o.os !== 'Unknown').map(o => ({ value: o.os, label: o.os, count: o.pageviews, })) } // Devices const devs = dashboard?.devices ?? [] if (devs.length > 0) { s.device = devs.filter(d => d.device && d.device !== 'Unknown').map(d => ({ value: d.device, label: d.device, count: d.pageviews, })) } // UTM from campaigns const camps = campaigns ?? [] if (camps.length > 0) { const sources = new Map() const mediums = new Map() const campNames = new Map() camps.forEach(c => { if (c.source) sources.set(c.source, (sources.get(c.source) ?? 0) + c.pageviews) if (c.medium) mediums.set(c.medium, (mediums.get(c.medium) ?? 0) + c.pageviews) if (c.campaign) campNames.set(c.campaign, (campNames.get(c.campaign) ?? 0) + c.pageviews) }) if (sources.size > 0) s.utm_source = [...sources.entries()].map(([v, c]) => ({ value: v, label: v, count: c })) if (mediums.size > 0) s.utm_medium = [...mediums.entries()].map(([v, c]) => ({ value: v, label: v, count: c })) if (campNames.size > 0) s.utm_campaign = [...campNames.entries()].map(([v, c]) => ({ value: v, label: v, count: c })) } return s }, [dashboard, campaigns]) // Show error toast on fetch failure useEffect(() => { if (dashboardError) { toast.error('Failed to load dashboard analytics') } }, [dashboardError]) // Track when data was last updated (for "Live · Xs ago" display) useEffect(() => { if (dashboard) lastUpdatedAtRef.current = Date.now() }, [dashboard]) // Save settings to localStorage const saveSettings = (type: string, newDateRange?: { start: string; end: string }) => { try { const settings = { type, dateRange: newDateRange || dateRange, todayInterval, multiDayInterval, lastUpdated: Date.now() } localStorage.setItem('pulse_dashboard_settings', JSON.stringify(settings)) } catch (e) { logger.error('Failed to save dashboard settings', e) } } // Save intervals when they change useEffect(() => { let type = 'custom' const today = formatDate(new Date()) if (dateRange.start === today && dateRange.end === today) type = 'today' else if (dateRange.start === getDateRange(7).start) type = '7' else if (dateRange.start === getDateRange(30).start) type = '30' const settings = { type, dateRange, todayInterval, multiDayInterval, lastUpdated: Date.now() } localStorage.setItem('pulse_dashboard_settings', JSON.stringify(settings)) }, [todayInterval, multiDayInterval]) // eslint-disable-line react-hooks/exhaustive-deps -- dateRange saved via saveSettings useEffect(() => { if (site?.domain) document.title = `${site.domain} | Pulse` }, [site?.domain]) // Skip the minimum-loading skeleton when SWR already has cached data // (prevents the 300ms flash when navigating back to the dashboard) const showSkeleton = useMinimumLoading(dashboardLoading && !dashboard) const fadeClass = useSkeletonFade(showSkeleton) if (showSkeleton) { return } if (!site) { return (

Site not found

) } return (

{site.name}

{site.domain}

{/* Realtime Indicator */}
{realtime} current visitors