'use client'
import { useState, useMemo } from 'react'
import { useTheme } from 'next-themes'
import {
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
ReferenceLine,
} from 'recharts'
import type { TooltipProps } from 'recharts'
import { formatNumber, formatDuration } from '@/lib/utils/format'
import { ArrowTopRightIcon, ArrowBottomRightIcon, DownloadIcon, BarChartIcon } from '@radix-ui/react-icons'
const COLORS = {
brand: '#FD5E0F',
success: '#10B981', // Emerald-500
danger: '#EF4444', // Red-500
}
const CHART_COLORS_LIGHT = {
border: '#E5E5E5',
text: '#171717',
textMuted: '#737373',
axis: '#A3A3A3',
tooltipBg: '#ffffff',
tooltipBorder: '#E5E5E5',
}
const CHART_COLORS_DARK = {
border: '#404040',
text: '#fafafa',
textMuted: '#a3a3a3',
axis: '#737373',
tooltipBg: '#262626',
tooltipBorder: '#404040',
}
interface DailyStat {
date: string
pageviews: number
visitors: number
}
interface Stats {
pageviews: number
visitors: number
bounce_rate: number
avg_duration: number
}
interface ChartProps {
data: DailyStat[]
prevData?: DailyStat[]
stats: Stats
prevStats?: Stats
interval: 'minute' | 'hour' | 'day' | 'month'
}
type MetricType = 'pageviews' | 'visitors' | 'bounce_rate' | 'avg_duration'
// * Custom tooltip with comparison and theme-aware styling
function ChartTooltip({
active,
payload,
label,
metric,
metricLabel,
formatNumberFn,
showComparison,
colors,
}: {
active?: boolean
payload?: Array<{ payload: { prevPageviews?: number; prevVisitors?: number }; value: number }>
label?: string
metric: 'visitors' | 'pageviews'
metricLabel: string
formatNumberFn: (n: number) => string
showComparison: boolean
colors: typeof CHART_COLORS_LIGHT
}) {
if (!active || !payload?.length || !label) return null
const d = payload[0]
const value = d.value as number
const prev = metric === 'visitors' ? d.payload.prevVisitors : d.payload.prevPageviews
const hasPrev = showComparison && prev != null
const delta =
hasPrev && (prev as number) > 0
? Math.round(((value - (prev as number)) / (prev as number)) * 100)
: null
return (
{label}
{formatNumberFn(value)}
{metricLabel}
{hasPrev && (
vs {formatNumberFn(prev as number)} prev
{delta !== null && (
0 ? COLORS.success : delta < 0 ? COLORS.danger : colors.textMuted,
}}
>
{delta > 0 ? '+' : ''}{delta}%
)}
)}
)
}
// * Compact Y-axis formatter: 1.5M, 12k, 99
function formatAxisValue(value: number): string {
if (value >= 1e6) return `${value / 1e6}M`
if (value >= 1000) return `${value / 1000}k`
return String(value)
}
export default function Chart({ data, prevData, stats, prevStats, interval }: ChartProps) {
const [metric, setMetric] = useState('visitors')
const [showComparison, setShowComparison] = useState(true)
const { resolvedTheme } = useTheme()
const colors = useMemo(
() => (resolvedTheme === 'dark' ? CHART_COLORS_DARK : CHART_COLORS_LIGHT),
[resolvedTheme]
)
// * Align current and previous data
const chartData = data.map((item, i) => {
// * Try to find matching previous item (assuming same length/order)
// * For more robustness, we could match by relative index
const prevItem = prevData?.[i]
// * Format date based on interval
let formattedDate: string
if (interval === 'minute') {
formattedDate = new Date(item.date).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })
} else if (interval === 'hour') {
formattedDate = new Date(item.date).toLocaleTimeString('en-US', { hour: 'numeric', minute: 'numeric' })
} else {
formattedDate = new Date(item.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
}
return {
date: formattedDate,
originalDate: item.date,
pageviews: item.pageviews,
visitors: item.visitors,
prevPageviews: prevItem?.pageviews,
prevVisitors: prevItem?.visitors,
}
})
// * Calculate trends
const calculateTrend = (current: number, previous?: number) => {
if (!previous) return null
if (previous === 0) return current > 0 ? 100 : 0
return Math.round(((current - previous) / previous) * 100)
}
const handleExport = () => {
const csvContent = "data:text/csv;charset=utf-8,"
+ "Date,Pageviews,Visitors\n"
+ data.map(row => `${new Date(row.date).toISOString()},${row.pageviews},${row.visitors}`).join("\n")
const encodedUri = encodeURI(csvContent)
const link = document.createElement("a")
link.setAttribute("href", encodedUri)
link.setAttribute("download", `pulse_export_${new Date().toISOString().split('T')[0]}.csv`)
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
const metrics = [
{
id: 'visitors',
label: 'Unique Visitors',
value: formatNumber(stats.visitors),
trend: calculateTrend(stats.visitors, prevStats?.visitors),
color: COLORS.brand,
invertTrend: false,
},
{
id: 'pageviews',
label: 'Total Pageviews',
value: formatNumber(stats.pageviews),
trend: calculateTrend(stats.pageviews, prevStats?.pageviews),
color: COLORS.brand,
invertTrend: false,
},
{
id: 'bounce_rate',
label: 'Bounce Rate',
value: `${Math.round(stats.bounce_rate)}%`,
trend: calculateTrend(stats.bounce_rate, prevStats?.bounce_rate),
color: COLORS.danger,
invertTrend: true, // Lower bounce rate is better
},
{
id: 'avg_duration',
label: 'Visit Duration',
value: formatDuration(stats.avg_duration),
trend: calculateTrend(stats.avg_duration, prevStats?.avg_duration),
color: COLORS.success,
invertTrend: false,
},
] as const
const activeMetric = metrics.find((m) => m.id === metric) || metrics[0]
const chartMetric = metric === 'visitors' || metric === 'pageviews' ? metric : 'visitors'
const metricLabel = chartMetric === 'pageviews' ? 'pageviews' : 'visitors'
const avg = chartData.length
? chartData.reduce((s, d) => s + (d[chartMetric] as number), 0) / chartData.length
: 0
const hasPrev = !!(prevData?.length && showComparison)
return (
{/* Stats Header (Interactive Tabs) */}
{metrics.map((item) => (
))}
{/* Chart Area */}
{prevData?.length ? (
) : null}
{/* Legend when comparing */}
{hasPrev && (
This period
Previous period
)}
{data.length === 0 ? (
No data for this period
Try a different date range
) : (
) => (
}
label={p.label as string}
metric={chartMetric}
metricLabel={metricLabel}
formatNumberFn={formatNumber}
showComparison={hasPrev}
colors={colors}
/>
)}
cursor={{ stroke: activeMetric.color, strokeDasharray: '4 4', strokeWidth: 1 }}
/>
{avg > 0 && (
)}
{hasPrev && (
)}
)}
)
}