Files
pulse/app/sites/[id]/page.tsx
Usman Baig ae0f6b8ffa Fix dashboard and map tab lag with memoization and code splitting
Memoize createMap() in DottedMap (was regenerating 8000 SVG dots every
render) and convert 11 heavy dashboard components to next/dynamic imports
so the page shell renders instantly instead of blocking on one massive
render pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 00:39:35 +01:00

653 lines
25 KiB
TypeScript

'use client'
import { logger } from '@/lib/utils/logger'
import { useCallback, useEffect, useState, useMemo } from 'react'
import { useParams, useRouter, useSearchParams } from 'next/navigation'
import {
getPerformanceByPage,
getTopPages,
getTopReferrers,
getCountries,
getCities,
getRegions,
getBrowsers,
getOS,
getDevices,
getCampaigns,
type Stats,
type DailyStat,
} from '@/lib/api/stats'
import { getDateRange, formatDate } from '@ciphera-net/ui'
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 } from '@/components/skeletons'
import FilterBar from '@/components/dashboard/FilterBar'
import AddFilterDropdown, { type FilterSuggestion, type FilterSuggestions } from '@/components/dashboard/AddFilterDropdown'
const Chart = dynamic(() => import('@/components/dashboard/Chart'))
const ContentStats = dynamic(() => import('@/components/dashboard/ContentStats'))
const TopReferrers = dynamic(() => import('@/components/dashboard/TopReferrers'))
const Locations = dynamic(() => import('@/components/dashboard/Locations'))
const TechSpecs = dynamic(() => import('@/components/dashboard/TechSpecs'))
const PerformanceStats = dynamic(() => import('@/components/dashboard/PerformanceStats'))
const GoalStats = dynamic(() => import('@/components/dashboard/GoalStats'))
const ScrollDepth = dynamic(() => import('@/components/dashboard/ScrollDepth'))
const Campaigns = dynamic(() => import('@/components/dashboard/Campaigns'))
const EventProperties = dynamic(() => import('@/components/dashboard/EventProperties'))
const ExportModal = dynamic(() => import('@/components/dashboard/ExportModal'))
import { type DimensionFilter, serializeFilters, parseFiltersFromURL } from '@/lib/filters'
import {
useDashboardOverview,
useDashboardPages,
useDashboardLocations,
useDashboardDevices,
useDashboardReferrers,
useDashboardPerformance,
useDashboardGoals,
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 === 'custom' && settings.dateRange) return settings.dateRange
return getDateRange(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 [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 [lastUpdatedAt, setLastUpdatedAt] = useState<number | null>(null)
const [, setTick] = useState(0)
// Dimension filters state
const searchParams = useSearchParams()
const [filters, setFilters] = useState<DimensionFilter[]>(() => {
const raw = searchParams.get('filters')
return raw ? parseFiltersFromURL(raw) : []
})
const filtersParam = useMemo(() => serializeFilters(filters), [filters])
// Selected event for property breakdown
const [selectedEvent, setSelectedEvent] = useState<string | null>(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<FilterSuggestion[]> => {
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<string, number>()
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])
// SWR hooks - replace manual useState + useEffect + setInterval polling
// Each hook handles its own refresh interval, deduplication, and error retry
// Filters are included in cache keys so changing filters auto-refetches
const { data: overview, isLoading: overviewLoading, error: overviewError } = useDashboardOverview(siteId, dateRange.start, dateRange.end, interval, filtersParam || undefined)
const { data: pages } = useDashboardPages(siteId, dateRange.start, dateRange.end, filtersParam || undefined)
const { data: locations } = useDashboardLocations(siteId, dateRange.start, dateRange.end, filtersParam || undefined)
const { data: devicesData } = useDashboardDevices(siteId, dateRange.start, dateRange.end, filtersParam || undefined)
const { data: referrers } = useDashboardReferrers(siteId, dateRange.start, dateRange.end, filtersParam || undefined)
const { data: performanceData } = useDashboardPerformance(siteId, dateRange.start, dateRange.end, filtersParam || undefined)
const { data: goalsData } = useDashboardGoals(siteId, dateRange.start, dateRange.end, 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 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 ?? []
// Build filter suggestions from current dashboard data
const filterSuggestions = useMemo<FilterSuggestions>(() => {
const s: FilterSuggestions = {}
// Pages
const topPages = pages?.top_pages ?? []
if (topPages.length > 0) {
s.page = topPages.map(p => ({ value: p.path, label: p.path, count: p.pageviews }))
}
// Referrers
const refs = referrers?.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 = locations?.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 = locations?.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 = locations?.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 = devicesData?.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 = devicesData?.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 = devicesData?.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<string, number>()
const mediums = new Map<string, number>()
const campNames = new Map<string, number>()
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
}, [pages, referrers, locations, devicesData, campaigns])
// 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 }) => {
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(overviewLoading && !overview)
if (showSkeleton) {
return <DashboardSkeleton />
}
if (!site) {
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
<p className="text-neutral-600 dark:text-neutral-400">Site not found</p>
</div>
)
}
return (
<div className="w-full max-w-6xl mx-auto px-4 sm:px-6 pb-8">
<div className="mb-8">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-4">
<div>
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white mb-2">
{site.name}
</h1>
<p className="text-neutral-600 dark:text-neutral-400">
{site.domain}
</p>
</div>
{/* Realtime Indicator */}
<button
onClick={() => router.push(`/sites/${siteId}/realtime`)}
className="flex items-center gap-2 px-3 py-1 bg-green-500/10 rounded-full border border-green-500/20 hover:bg-green-500/20 transition-colors cursor-pointer"
>
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-500 opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span>
</span>
<span className="text-sm font-medium text-green-700 dark:text-green-400">
{realtime} current visitors
</span>
</button>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2">
<Button
onClick={() => setIsExportModalOpen(true)}
variant="primary"
className="hidden md:inline-flex gap-2 text-sm"
>
<DownloadIcon className="w-4 h-4" />
Export
</Button>
<Select
variant="input"
className="min-w-[140px]"
value={
dateRange.start === formatDate(new Date()) && dateRange.end === formatDate(new Date())
? 'today'
: dateRange.start === getDateRange(7).start
? '7'
: dateRange.start === getDateRange(30).start
? '30'
: 'custom'
}
onChange={(value) => {
if (value === '7') {
const range = getDateRange(7)
setDateRange(range)
saveSettings('7', range)
}
else if (value === '30') {
const range = getDateRange(30)
setDateRange(range)
saveSettings('30', range)
}
else if (value === 'today') {
const today = formatDate(new Date())
const range = { start: today, end: today }
setDateRange(range)
saveSettings('today', range)
}
else if (value === 'custom') {
setIsDatePickerOpen(true)
}
}}
options={[
{ value: 'today', label: 'Today' },
{ value: '7', label: 'Last 7 days' },
{ value: '30', label: 'Last 30 days' },
{ value: 'custom', label: 'Custom' },
]}
/>
</div>
</div>
</div>
</div>
{/* Dimension Filters */}
<div className="flex items-center gap-2 flex-wrap mb-2">
<AddFilterDropdown onAdd={handleAddFilter} suggestions={filterSuggestions} onFetchSuggestions={handleFetchSuggestions} />
<FilterBar filters={filters} onRemove={handleRemoveFilter} onClear={handleClearFilters} />
</div>
{/* Advanced Chart with Integrated Stats */}
<div className="mb-8">
<Chart
data={dailyStats}
prevData={prevDailyStats}
stats={stats}
prevStats={prevStats}
interval={dateRange.start === dateRange.end ? todayInterval : multiDayInterval}
dateRange={dateRange}
todayInterval={todayInterval}
setTodayInterval={setTodayInterval}
multiDayInterval={multiDayInterval}
setMultiDayInterval={setMultiDayInterval}
lastUpdatedAt={lastUpdatedAt}
annotations={annotations}
canManageAnnotations={true}
onCreateAnnotation={handleCreateAnnotation}
onUpdateAnnotation={handleUpdateAnnotation}
onDeleteAnnotation={handleDeleteAnnotation}
/>
</div>
{/* Performance Stats - Only show if enabled */}
{site.enable_performance_insights && (
<div className="mb-8">
<PerformanceStats
stats={performanceData?.performance ?? { lcp: 0, cls: 0, inp: 0 }}
performanceByPage={performanceData?.performance_by_page ?? null}
siteId={siteId}
startDate={dateRange.start}
endDate={dateRange.end}
getPerformanceByPage={getPerformanceByPage}
/>
</div>
)}
<div className="grid gap-6 lg:grid-cols-2 mb-8">
<ContentStats
topPages={pages?.top_pages ?? []}
entryPages={pages?.entry_pages ?? []}
exitPages={pages?.exit_pages ?? []}
domain={site.domain}
collectPagePaths={site.collect_page_paths ?? true}
siteId={siteId}
dateRange={dateRange}
onFilter={handleAddFilter}
/>
<TopReferrers
referrers={referrers?.top_referrers ?? []}
collectReferrers={site.collect_referrers ?? true}
siteId={siteId}
dateRange={dateRange}
onFilter={handleAddFilter}
/>
</div>
<div className="grid gap-6 lg:grid-cols-2 mb-8">
<Locations
countries={locations?.countries ?? []}
cities={locations?.cities ?? []}
regions={locations?.regions ?? []}
geoDataLevel={site.collect_geo_data || 'full'}
siteId={siteId}
dateRange={dateRange}
onFilter={handleAddFilter}
/>
<TechSpecs
browsers={devicesData?.browsers ?? []}
os={devicesData?.os ?? []}
devices={devicesData?.devices ?? []}
screenResolutions={devicesData?.screen_resolutions ?? []}
collectDeviceInfo={site.collect_device_info ?? true}
collectScreenResolution={site.collect_screen_resolution ?? true}
siteId={siteId}
dateRange={dateRange}
onFilter={handleAddFilter}
/>
</div>
<div className="grid gap-6 lg:grid-cols-2 mb-8">
<Campaigns siteId={siteId} dateRange={dateRange} filters={filtersParam || undefined} onFilter={handleAddFilter} />
<GoalStats
goalCounts={(goalsData?.goal_counts ?? []).filter(g => !/^scroll_\d+$/.test(g.event_name))}
onSelectEvent={setSelectedEvent}
/>
</div>
<div className="mb-8">
<ScrollDepth goalCounts={goalsData?.goal_counts ?? []} totalPageviews={stats.pageviews} />
</div>
{/* Event Properties Breakdown */}
{selectedEvent && (
<div className="mb-8">
<EventProperties
siteId={siteId}
eventName={selectedEvent}
dateRange={dateRange}
onClose={() => setSelectedEvent(null)}
/>
</div>
)}
<DatePicker
isOpen={isDatePickerOpen}
onClose={() => setIsDatePickerOpen(false)}
onApply={(range) => {
setDateRange(range)
saveSettings('custom', range)
setIsDatePickerOpen(false)
}}
initialRange={dateRange}
/>
<ExportModal
isOpen={isExportModalOpen}
onClose={() => setIsExportModalOpen(false)}
data={dailyStats}
stats={stats}
topPages={pages?.top_pages}
topReferrers={referrers?.top_referrers}
campaigns={campaigns}
/>
</div>
)
}