'use client' import { useState, useMemo } from 'react' import { useTheme } from '@ciphera-net/ui' import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ReferenceLine, } from 'recharts' import type { TooltipProps } from 'recharts' import { formatNumber, formatDuration } from '@/lib/utils/format' import { ArrowUpRightIcon, ArrowDownRightIcon, BarChartIcon, Select } from '@ciphera-net/ui' import { Checkbox } from '@ciphera-net/ui' 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', } export interface DailyStat { date: string pageviews: number visitors: number bounce_rate: number avg_duration: 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' dateRange: { start: string, end: string } todayInterval: 'minute' | 'hour' setTodayInterval: (interval: 'minute' | 'hour') => void multiDayInterval: 'hour' | 'day' setMultiDayInterval: (interval: 'hour' | 'day') => void } 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; prevBounceRate?: number; prevAvgDuration?: number }; value: number }> label?: string metric: MetricType 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; prevBounceRate?: number; prevAvgDuration?: number; visitors?: number; pageviews?: number; bounce_rate?: number; avg_duration?: 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) let prev: number | undefined switch (metric) { case 'visitors': prev = current?.payload?.prevVisitors; break; case 'pageviews': prev = current?.payload?.prevPageviews; break; case 'bounce_rate': prev = current?.payload?.prevBounceRate; break; case 'avg_duration': prev = current?.payload?.prevAvgDuration; break; } const hasPrev = showComparison && prev != null const delta = hasPrev && (prev as number) > 0 ? Math.round(((value - (prev as number)) / (prev as number)) * 100) : null const formatValue = (v: number) => { if (metric === 'bounce_rate') return `${Math.round(v)}%` if (metric === 'avg_duration') return formatDuration(v) return formatNumberFn(v) } return (
{label}
{formatValue(value)} {metricLabel}
{hasPrev && (
vs {formatValue(prev as number)} prev {delta !== null && ( 0 ? (metric === 'bounce_rate' ? COLORS.danger : COLORS.success) : delta < 0 ? (metric === 'bounce_rate' ? COLORS.success : 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, dateRange, todayInterval, setTodayInterval, multiDayInterval, setMultiDayInterval }: ChartProps) { const [metric, setMetric] = useState('visitors') const [showComparison, setShowComparison] = useState(false) 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 // * At 12:00 AM: date only (used for X-axis ticks). Non-midnight: date + time for tooltip only. formattedDate = isMidnight ? d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) + ' 12:00 AM' : 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, bounce_rate: item.bounce_rate, avg_duration: item.avg_duration, prevPageviews: prevItem?.pageviews, prevVisitors: prevItem?.visitors, prevBounceRate: prevItem?.bounce_rate, prevAvgDuration: prevItem?.avg_duration, } }) // * 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 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.brand, 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.brand, invertTrend: false, }, ] as const const activeMetric = metrics.find((m) => m.id === metric) || metrics[0] const chartMetric = metric const metricLabel = metrics.find(m => m.id === metric)?.label || 'visitors' const avg = chartData.length ? chartData.reduce((s, d) => s + (d[chartMetric] as number), 0) / chartData.length : 0 const hasPrev = !!(prevData?.length && showComparison) // * In hourly view, only show X-axis labels at 12:00 AM (date + 12:00 AM). const midnightTicks = interval === 'hour' ? (() => { const t = chartData .filter((_, i) => { const d = new Date(data[i].date) return d.getHours() === 0 && d.getMinutes() === 0 }) .map((c) => c.date) return t.length > 0 ? t : undefined })() : undefined // * In daily view, only show the date at each day (12:00 AM / start-of-day mark), no time. const dayTicks = interval === 'day' && chartData.length > 0 ? chartData.map((c) => c.date) : undefined return (
{/* Stats Header (Interactive Tabs) */}
{metrics.map((item) => ( ))}
{/* Chart Area */}
{/* Toolbar Row */}
{/* Left side: Legend */}
{hasPrev && (
Current Previous
)}
{/* Right side: Controls */}
{dateRange.start === dateRange.end && ( setMultiDayInterval(value as 'hour' | 'day')} options={[ { value: 'hour', label: '1 hour' }, { value: 'day', label: '1 day' }, ]} className="min-w-[100px]" /> )} {prevData?.length ? ( ) : null} {/* Vertical Separator */}
{data.length === 0 ? (

No data for this period

Try a different date range

) : (
{ if (metric === 'bounce_rate') return `${val}%` if (metric === 'avg_duration') return formatDuration(val) return formatAxisValue(val) }} /> ) => ( } 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 && ( )}
)}
) }