writekit/frontends/studio/src/pages/AnalyticsPage.tsx

319 lines
12 KiB
TypeScript
Raw Normal View History

2026-01-09 00:16:46 +02:00
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<HTMLCanvasElement>(null)
const chartInstance = useRef<Chart | null>(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 <EmptyState Icon={Icons.AlertCircle} title="Failed to load analytics" description={error.message} />
if (!displayData) return <AnalyticsPageSkeleton />
const change = formatChange(displayData.views_change)
return (
<div>
<PageHeader>
<Tabs
value={String(days)}
onChange={(v) => $days.set(Number(v))}
tabs={periodTabs}
/>
</PageHeader>
{/* Panel container - full-bleed borders */}
<div className="-mx-6 lg:-mx-10 mt-6">
{/* Stats row - 4 columns on lg, 2 on mobile */}
<div className="relative">
{/* Vertical dividers at column boundaries (lg: 4 cols, mobile: 2 cols) */}
<div className="absolute top-0 bottom-0 left-1/2 border-l border-border lg:hidden" />
<div className="hidden lg:block absolute top-0 bottom-0 border-l border-border" style={{ left: '25%' }} />
<div className="hidden lg:block absolute top-0 bottom-0 border-l border-border" style={{ left: '50%' }} />
<div className="hidden lg:block absolute top-0 bottom-0 border-l border-border" style={{ left: '75%' }} />
<div className="grid grid-cols-2 lg:grid-cols-4">
<div className="py-5 pl-6 lg:pl-10 pr-6">
<div className="text-xs text-muted mb-1">Total Views</div>
<div className="text-2xl font-semibold tracking-tight">{displayData.total_views.toLocaleString()}</div>
<div className={`text-xs mt-1 ${change.positive ? 'text-success' : 'text-danger'}`}>
{change.text} vs last period
</div>
</div>
<div className="py-5 px-6 lg:pr-6">
<div className="text-xs text-muted mb-1">Page Views</div>
<div className="text-2xl font-semibold tracking-tight">{displayData.total_page_views.toLocaleString()}</div>
</div>
<div className="py-5 px-6 lg:pl-6 border-t border-border lg:border-t-0">
<div className="text-xs text-muted mb-1">Unique Visitors</div>
<div className="text-2xl font-semibold tracking-tight">{displayData.unique_visitors.toLocaleString()}</div>
</div>
<div className="py-5 pr-6 lg:pr-10 pl-6 border-t border-border lg:border-t-0">
<div className="text-xs text-muted mb-1">Bandwidth</div>
<div className="text-2xl font-semibold tracking-tight">{formatBytes(displayData.total_bandwidth)}</div>
</div>
</div>
</div>
{/* Horizontal divider */}
<div className="border-t border-border" />
{/* Chart section */}
{displayData.views_by_day.length > 0 && (
<>
<div className="px-6 lg:px-10 py-6">
<div className="text-xs font-medium text-muted uppercase tracking-wide mb-4">Views Over Time</div>
<div className="h-48">
<canvas ref={chartRef} />
</div>
</div>
<div className="border-t border-border" />
</>
)}
{/* Breakdown sections - 2 columns */}
<div className="relative">
{/* Vertical divider at center on lg */}
<div className="hidden lg:block absolute top-0 bottom-0 left-1/2 border-l border-border" />
<div className="grid lg:grid-cols-2">
{/* Top Pages */}
{displayData.top_pages.length > 0 && (
<div className="py-6 pl-6 lg:pl-10 pr-6 border-b border-border lg:border-b-0">
<div className="text-xs font-medium text-muted uppercase tracking-wide mb-4">Top Pages</div>
<BreakdownList
items={displayData.top_pages.map(p => ({
label: p.path,
value: p.views,
percentage: (p.views / displayData.total_page_views) * 100,
}))}
/>
</div>
)}
{/* Top Referrers */}
{displayData.top_referrers.length > 0 && (
<div className="py-6 pr-6 lg:pr-10 pl-6 border-b border-border lg:border-b-0">
<div className="text-xs font-medium text-muted uppercase tracking-wide mb-4">Top Referrers</div>
<BreakdownList
items={displayData.top_referrers.map(r => {
const label = r.referrer || 'Direct'
return {
label,
value: r.views,
percentage: (r.views / displayData.total_views) * 100,
Icon: getReferrerIcon(label),
}
})}
/>
</div>
)}
</div>
</div>
{/* Row 2: Browsers & Devices */}
{(displayData.browsers.length > 0 || displayData.devices.length > 0) && (
<>
<div className="border-t border-border" />
<div className="relative">
<div className="hidden lg:block absolute top-0 bottom-0 left-1/2 border-l border-border" />
<div className="grid lg:grid-cols-2">
{displayData.browsers.length > 0 && (
<div className="py-6 pl-6 lg:pl-10 pr-6 border-b border-border lg:border-b-0">
<div className="text-xs font-medium text-muted uppercase tracking-wide mb-4">Browsers</div>
<BreakdownList
items={displayData.browsers.map(b => ({
label: b.name,
value: b.count,
percentage: (b.count / displayData.unique_visitors) * 100,
Icon: getBrowserIcon(b.name) || undefined,
}))}
/>
</div>
)}
{displayData.devices.length > 0 && (
<div className="py-6 pr-6 lg:pr-10 pl-6 border-b border-border lg:border-b-0">
<div className="text-xs font-medium text-muted uppercase tracking-wide mb-4">Devices</div>
<BreakdownList
items={displayData.devices.map(d => ({
label: d.name,
value: d.count,
percentage: (d.count / displayData.unique_visitors) * 100,
Icon: getDeviceIcon(d.name) || undefined,
}))}
/>
</div>
)}
</div>
</div>
</>
)}
{/* Row 3: Countries & OS */}
{(displayData.countries.length > 0 || displayData.os.length > 0) && (
<>
<div className="border-t border-border" />
<div className="relative">
<div className="hidden lg:block absolute top-0 bottom-0 left-1/2 border-l border-border" />
<div className="grid lg:grid-cols-2">
{displayData.countries.length > 0 && (
<div className="py-6 pl-6 lg:pl-10 pr-6 border-b border-border lg:border-b-0">
<div className="text-xs font-medium text-muted uppercase tracking-wide mb-4">Countries</div>
<BreakdownList
items={displayData.countries.map(c => ({
label: c.name,
value: c.count,
percentage: (c.count / displayData.unique_visitors) * 100,
flagUrl: getCountryFlagUrl(c.name, 40) || undefined,
}))}
/>
</div>
)}
{displayData.os.length > 0 && (
<div className="py-6 pr-6 lg:pr-10 pl-6">
<div className="text-xs font-medium text-muted uppercase tracking-wide mb-4">Operating Systems</div>
<BreakdownList
items={displayData.os.map(o => ({
label: o.name,
value: o.count,
percentage: (o.count / displayData.unique_visitors) * 100,
Icon: getOSIcon(o.name) || undefined,
}))}
/>
</div>
)}
</div>
</div>
</>
)}
</div>
</div>
)
}