import { useEffect, useRef } from 'react' import { useStore } from '@nanostores/react' import { Chart, LineController, LineElement, PointElement, LinearScale, CategoryScale, Filler, Tooltip } from 'chart.js' import { $analytics, $days } from '../stores/analytics' import { BreakdownList, AnalyticsPageSkeleton, EmptyState, PageHeader } from '../components/shared' import { Tabs } from '../components/ui' import { Icons, getReferrerIcon, getCountryFlagUrl, getBrowserIcon, getDeviceIcon, getOSIcon } from '../components/shared/Icons' Chart.register(LineController, LineElement, PointElement, LinearScale, CategoryScale, Filler, Tooltip) const periodTabs = [ { value: '7', label: '7d' }, { value: '30', label: '30d' }, { value: '90', label: '90d' }, ] function formatBytes(bytes: number): string { if (bytes === 0) return '0 B' const k = 1024 const sizes = ['B', 'KB', 'MB', 'GB'] const i = Math.floor(Math.log(bytes) / Math.log(k)) return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}` } function formatChange(change: number): { text: string; positive: boolean } { const sign = change >= 0 ? '+' : '' return { text: `${sign}${change.toFixed(1)}%`, positive: change >= 0, } } export default function AnalyticsPage() { const { data, error } = useStore($analytics) const days = useStore($days) const chartRef = useRef(null) const chartInstance = useRef(null) const prevDataRef = useRef(data) useEffect(() => { if (!data?.views_by_day?.length || !chartRef.current) return if (chartInstance.current) { chartInstance.current.destroy() } const ctx = chartRef.current.getContext('2d') if (!ctx) return const sortedData = [...data.views_by_day].sort((a, b) => a.date.localeCompare(b.date)) const fontFamily = '"SF Mono", "JetBrains Mono", "Fira Code", Consolas, monospace' chartInstance.current = new Chart(ctx, { type: 'line', data: { labels: sortedData.map(d => new Date(d.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })), datasets: [{ data: sortedData.map(d => d.views), borderColor: '#10b981', backgroundColor: 'rgba(16, 185, 129, 0.08)', borderWidth: 2, fill: true, tension: 0.3, pointRadius: 3, pointBackgroundColor: '#10b981', pointBorderColor: '#10b981', pointHoverRadius: 6, pointHoverBackgroundColor: '#10b981', pointHoverBorderColor: '#ffffff', pointHoverBorderWidth: 2, }] }, options: { responsive: true, maintainAspectRatio: false, interaction: { intersect: false, mode: 'index', }, plugins: { tooltip: { backgroundColor: '#0a0a0a', titleColor: '#fafafa', bodyColor: '#fafafa', titleFont: { family: fontFamily, size: 11 }, bodyFont: { family: fontFamily, size: 12 }, padding: 10, cornerRadius: 0, displayColors: false, callbacks: { label: (ctx) => `${(ctx.parsed.y ?? 0).toLocaleString()} views` } } }, scales: { x: { grid: { display: false }, border: { display: false }, ticks: { color: '#737373', font: { family: fontFamily, size: 10 }, padding: 8, maxRotation: 0, } }, y: { grid: { color: '#e5e5e5' }, border: { display: false }, ticks: { color: '#737373', font: { family: fontFamily, size: 10 }, padding: 12, }, beginAtZero: true } } } }) return () => { if (chartInstance.current) { chartInstance.current.destroy() } } }, [data]) // Keep previous data while loading to prevent flickering if (data) { prevDataRef.current = data } const displayData = data || prevDataRef.current if (error && !displayData) return if (!displayData) return const change = formatChange(displayData.views_change) return (
$days.set(Number(v))} tabs={periodTabs} /> {/* Panel container - full-bleed borders */}
{/* Stats row - 4 columns on lg, 2 on mobile */}
{/* Vertical dividers at column boundaries (lg: 4 cols, mobile: 2 cols) */}
Total Views
{displayData.total_views.toLocaleString()}
{change.text} vs last period
Page Views
{displayData.total_page_views.toLocaleString()}
Unique Visitors
{displayData.unique_visitors.toLocaleString()}
Bandwidth
{formatBytes(displayData.total_bandwidth)}
{/* Horizontal divider */}
{/* Chart section */} {displayData.views_by_day.length > 0 && ( <>
Views Over Time
)} {/* Breakdown sections - 2 columns */}
{/* Vertical divider at center on lg */}
{/* Top Pages */} {displayData.top_pages.length > 0 && (
Top Pages
({ label: p.path, value: p.views, percentage: (p.views / displayData.total_page_views) * 100, }))} />
)} {/* Top Referrers */} {displayData.top_referrers.length > 0 && (
Top Referrers
{ const label = r.referrer || 'Direct' return { label, value: r.views, percentage: (r.views / displayData.total_views) * 100, Icon: getReferrerIcon(label), } })} />
)}
{/* Row 2: Browsers & Devices */} {(displayData.browsers.length > 0 || displayData.devices.length > 0) && ( <>
{displayData.browsers.length > 0 && (
Browsers
({ label: b.name, value: b.count, percentage: (b.count / displayData.unique_visitors) * 100, Icon: getBrowserIcon(b.name) || undefined, }))} />
)} {displayData.devices.length > 0 && (
Devices
({ label: d.name, value: d.count, percentage: (d.count / displayData.unique_visitors) * 100, Icon: getDeviceIcon(d.name) || undefined, }))} />
)}
)} {/* Row 3: Countries & OS */} {(displayData.countries.length > 0 || displayData.os.length > 0) && ( <>
{displayData.countries.length > 0 && (
Countries
({ label: c.name, value: c.count, percentage: (c.count / displayData.unique_visitors) * 100, flagUrl: getCountryFlagUrl(c.name, 40) || undefined, }))} />
)} {displayData.os.length > 0 && (
Operating Systems
({ label: o.name, value: o.count, percentage: (o.count / displayData.unique_visitors) * 100, Icon: getOSIcon(o.name) || undefined, }))} />
)}
)}
) }