'use client' import { useState, useMemo, useRef, useCallback } 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, formatUpdatedAgo } from '@ciphera-net/ui' import { ArrowUpRightIcon, ArrowDownRightIcon, BarChartIcon, Select, Button, DownloadIcon } from '@ciphera-net/ui' import { Checkbox } from '@ciphera-net/ui' const COLORS = { brand: 'var(--color-brand-orange)', success: 'var(--color-success)', danger: 'var(--color-error)', } const CHART_COLORS_LIGHT = { border: 'var(--color-neutral-200)', text: 'var(--color-neutral-900)', textMuted: 'var(--color-neutral-500)', axis: 'var(--color-neutral-400)', tooltipBg: '#ffffff', tooltipBorder: 'var(--color-neutral-200)', } const CHART_COLORS_DARK = { border: 'var(--color-neutral-700)', text: 'var(--color-neutral-50)', textMuted: 'var(--color-neutral-400)', axis: 'var(--color-neutral-500)', tooltipBg: 'var(--color-neutral-800)', tooltipBorder: 'var(--color-neutral-700)', } 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 /** Optional: callback when user requests chart export (parent can open ExportModal or handle export) */ onExportChart?: () => void /** Optional: timestamp of last data fetch for "Live · Xs ago" indicator */ lastUpdatedAt?: number | null } 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, prevPeriodLabel, 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 prevPeriodLabel?: string 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)} {prevPeriodLabel ? `(${prevPeriodLabel})` : '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) } // * Compact duration for Y-axis ticks (avoids truncation: "5m" not "5m 0s") function formatAxisDuration(seconds: number): string { if (!seconds) return '0s' const m = Math.floor(seconds / 60) const s = Math.floor(seconds % 60) if (m > 0) return s > 0 ? `${m}m ${s}s` : `${m}m` return `${s}s` } // * Returns human-readable label for the previous comparison period (e.g. "Feb 10" or "Jan 5 – Feb 4") function getPrevDateRangeLabel(dateRange: { start: string; end: string }): string { const startDate = new Date(dateRange.start) const endDate = new Date(dateRange.end) const duration = endDate.getTime() - startDate.getTime() if (duration === 0) { const prev = new Date(startDate.getTime() - 24 * 60 * 60 * 1000) return prev.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) } const prevEnd = new Date(startDate.getTime() - 24 * 60 * 60 * 1000) const prevStart = new Date(prevEnd.getTime() - duration) const fmt = (d: Date) => d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) return `${fmt(prevStart)} – ${fmt(prevEnd)}` } // * Returns short trend context (e.g. "vs yesterday", "vs previous 7 days") function getTrendContext(dateRange: { start: string; end: string }): string { const startDate = new Date(dateRange.start) const endDate = new Date(dateRange.end) const duration = endDate.getTime() - startDate.getTime() if (duration === 0) return 'vs yesterday' const days = Math.round(duration / (24 * 60 * 60 * 1000)) if (days === 1) return 'vs yesterday' return `vs previous ${days} days` } // * Mini sparkline SVG for KPI cards function Sparkline({ data, dataKey, color, width = 56, height = 20, }: { data: Array> dataKey: string color: string width?: number height?: number }) { if (!data.length) return null const values = data.map((d) => Number(d[dataKey] ?? 0)) const max = Math.max(...values, 1) const min = Math.min(...values, 0) const range = max - min || 1 const padding = 2 const w = width - padding * 2 const h = height - padding * 2 const points = values.map((v, i) => { const x = padding + (i / Math.max(values.length - 1, 1)) * w const y = padding + h - ((v - min) / range) * h return `${x},${y}` }) const pathD = points.length > 1 ? `M ${points.join(' L ')}` : `M ${points[0]} L ${points[0]}` return ( ) } export default function Chart({ data, prevData, stats, prevStats, interval, dateRange, todayInterval, setTodayInterval, multiDayInterval, setMultiDayInterval, onExportChart, lastUpdatedAt, }: ChartProps) { const [metric, setMetric] = useState('visitors') const [showComparison, setShowComparison] = useState(false) const chartContainerRef = useRef(null) const { resolvedTheme } = useTheme() const handleExportChart = useCallback(async () => { if (onExportChart) { onExportChart() return } if (!chartContainerRef.current) return try { const { toPng } = await import('html-to-image') const dataUrl = await toPng(chartContainerRef.current, { cacheBust: true, backgroundColor: resolvedTheme === 'dark' ? 'var(--color-neutral-900)' : '#ffffff', }) const link = document.createElement('a') link.download = `chart-${dateRange.start}-${dateRange.end}.png` link.href = dataUrl link.click() } catch { // Fallback: do nothing if export fails } }, [onExportChart, dateRange, resolvedTheme]) 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 prevPeriodLabel = prevData?.length ? getPrevDateRangeLabel(dateRange) : '' const trendContext = getTrendContext(dateRange) const avg = chartData.length ? chartData.reduce((s, d) => s + (d[chartMetric] as number), 0) / chartData.length : 0 const hasPrev = !!(prevData?.length && showComparison) const hasData = data.length > 0 const hasAnyNonZero = hasData && chartData.some((d) => (d[chartMetric] as number) > 0) // * 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 (
{/* * Subtle live/updated indicator in bottom-right corner */} {lastUpdatedAt != null && (
Live · {formatUpdatedAgo(lastUpdatedAt)}
)} {/* Stats Header (Interactive Tabs) */}
{metrics.map((item) => ( ))}
{/* Chart Area */}
{/* Toolbar Row */}
{/* Left side: Legend */}
{hasPrev && (
Current Previous{prevPeriodLabel ? ` (${prevPeriodLabel})` : ''}
)}
{/* Right side: Controls */}
Group by: {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 ? (
{showComparison && prevPeriodLabel && ( ({prevPeriodLabel}) )}
) : null} {/* Vertical Separator */}
{!hasData ? (

No data for this period

Try a different date range

) : !hasAnyNonZero ? (

No {metricLabel.toLowerCase()} data for this period

Try selecting another metric or date range

) : (
{metricLabel}
{ if (metric === 'bounce_rate') return `${val}%` if (metric === 'avg_duration') return formatAxisDuration(val) return formatAxisValue(val) }} /> ) => ( } label={p.label as string} metric={chartMetric} metricLabel={metricLabel} formatNumberFn={formatNumber} showComparison={hasPrev} prevPeriodLabel={prevPeriodLabel} colors={colors} /> )} cursor={{ stroke: activeMetric.color, strokeDasharray: '4 4', strokeWidth: 1 }} /> {avg > 0 && ( )} {hasPrev && ( )}
)}
) }