'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 // * Recharts sends one payload entry per Area; order can be [prevSeries, currentSeries]. // * Use the entry for the current metric so the tooltip shows today's value, not yesterday's. type PayloadItem = { dataKey?: string; value?: number; payload: { prevPageviews?: number; prevVisitors?: number; visitors?: number; pageviews?: number } } const items = payload as PayloadItem[] const current = items.find((p) => p.dataKey === metric) ?? items[items.length - 1] const value = Number(current?.value ?? (current?.payload as Record)?.[metric] ?? 0) const prev = metric === 'visitors' ? current?.payload?.prevVisitors : current?.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') { const d = new Date(item.date) const isMidnight = d.getHours() === 0 && d.getMinutes() === 0 formattedDate = isMidnight ? d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : d.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 && ( )}
)}
) }