'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, 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)', grid: 'var(--color-neutral-100)', 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)', grid: 'var(--color-neutral-800)', 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 onExportChart?: () => void lastUpdatedAt?: number | null } type MetricType = 'pageviews' | 'visitors' | 'bounce_rate' | 'avg_duration' // ─── Helpers ───────────────────────────────────────────────────────── function formatAxisValue(value: number): string { if (value >= 1e6) return `${+(value / 1e6).toFixed(1)}M` if (value >= 1000) return `${+(value / 1000).toFixed(1)}k` if (!Number.isInteger(value)) return value.toFixed(1) return String(value) } 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` } function formatAvgLabel(value: number, metric: MetricType): string { if (metric === 'bounce_rate') return `${Math.round(value)}%` if (metric === 'avg_duration') return formatAxisDuration(value) if (metric === 'visitors' || metric === 'pageviews') return formatAxisValue(Math.round(value)) return formatAxisValue(value) } 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)}` } 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` } // ─── Tooltip ───────────────────────────────────────────────────────── function ChartTooltip({ active, payload, label, metric, metricLabel, formatNumberFn, showComparison, prevPeriodLabel, colors, }: { active?: boolean payload?: Array<{ payload: Record; value: number; dataKey?: string }> 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 const current = payload.find((p) => p.dataKey === metric) ?? payload[payload.length - 1] const value = Number(current?.value ?? current?.payload?.[metric] ?? 0) const prevKey = metric === 'visitors' ? 'prevVisitors' : metric === 'pageviews' ? 'prevPageviews' : metric === 'bounce_rate' ? 'prevBounceRate' : 'prevAvgDuration' const prev = current?.payload?.[prevKey] const hasPrev = showComparison && prev != null const delta = hasPrev && prev > 0 ? Math.round(((value - prev) / prev) * 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)} {prevPeriodLabel ? `(${prevPeriodLabel})` : ''} {delta !== null && ( 0 ? (metric === 'bounce_rate' ? COLORS.danger : COLORS.success) : delta < 0 ? (metric === 'bounce_rate' ? COLORS.success : COLORS.danger) : colors.textMuted, }} > {delta > 0 ? '+' : ''}{delta}% )}
)}
) } // ─── Chart Component ───────────────────────────────────────────────── 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 { /* noop */ } }, [onExportChart, dateRange, resolvedTheme]) const colors = useMemo( () => (resolvedTheme === 'dark' ? CHART_COLORS_DARK : CHART_COLORS_LIGHT), [resolvedTheme] ) // ─── Data ────────────────────────────────────────────────────────── const chartData = data.map((item, i) => { const prevItem = prevData?.[i] 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' }) + ' 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, } }) // ─── Metrics ─────────────────────────────────────────────────────── 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' as const, label: 'Unique Visitors', value: formatNumber(stats.visitors), trend: calculateTrend(stats.visitors, prevStats?.visitors), invertTrend: false }, { id: 'pageviews' as const, label: 'Total Pageviews', value: formatNumber(stats.pageviews), trend: calculateTrend(stats.pageviews, prevStats?.pageviews), invertTrend: false }, { id: 'bounce_rate' as const, label: 'Bounce Rate', value: `${Math.round(stats.bounce_rate)}%`, trend: calculateTrend(stats.bounce_rate, prevStats?.bounce_rate), invertTrend: true }, { id: 'avg_duration' as const, label: 'Visit Duration', value: formatDuration(stats.avg_duration), trend: calculateTrend(stats.avg_duration, prevStats?.avg_duration), invertTrend: false }, ] const activeMetric = metrics.find((m) => m.id === metric) || metrics[0] const metricLabel = activeMetric.label const prevPeriodLabel = prevData?.length ? getPrevDateRangeLabel(dateRange) : '' const trendContext = getTrendContext(dateRange) const avg = chartData.length ? chartData.reduce((s, d) => s + (d[metric] as number), 0) / chartData.length : 0 const hasPrev = !!(prevData?.length && showComparison) const hasData = data.length > 0 const hasAnyNonZero = hasData && chartData.some((d) => (d[metric] as number) > 0) // Count metrics should never show decimal Y-axis ticks const isCountMetric = metric === 'visitors' || metric === 'pageviews' // ─── X-Axis Ticks ───────────────────────────────────────────────── 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 const dayTicks = interval === 'day' && chartData.length > 0 ? chartData.map((c) => c.date) : undefined // ─── Trend Badge ────────────────────────────────────────────────── function TrendBadge({ trend, invert }: { trend: number | null; invert: boolean }) { if (trend === null) return const effective = invert ? -trend : trend const isPositive = effective > 0 const isNegative = effective < 0 return ( {isPositive ? : isNegative ? : null} {Math.abs(trend)}% ) } // ─── Render ──────────────────────────────────────────────────────── return (
{/* Stat Cards */}
{metrics.map((item) => { const isActive = metric === item.id return ( ) })}
{/* Chart Area */}
{/* Toolbar */}
{/* Left: metric label + avg badge */}
{metricLabel} {hasAnyNonZero && avg > 0 && ( Avg: {formatAvgLabel(avg, metric)} )} {hasPrev && (
Current Previous{prevPeriodLabel ? ` (${prevPeriodLabel})` : ''}
)}
{/* Right: controls */}
{dateRange.start === dateRange.end ? ( setMultiDayInterval(value as 'hour' | 'day')} options={[ { value: 'hour', label: '1 hour' }, { value: 'day', label: '1 day' }, ]} className="min-w-[90px]" /> )} {prevData?.length ? ( ) : null}
{!hasData ? (

No data for this period

) : !hasAnyNonZero ? (

No {metricLabel.toLowerCase()} recorded

) : (
{ if (metric === 'bounce_rate') return `${val}%` if (metric === 'avg_duration') return formatAxisDuration(val) return formatAxisValue(val) }} /> ) => ( ; value: number; dataKey?: string }>} label={p.label as string} metric={metric} metricLabel={metricLabel} formatNumberFn={formatNumber} showComparison={hasPrev} prevPeriodLabel={prevPeriodLabel} colors={colors} /> )} cursor={{ stroke: colors.axis, strokeOpacity: 0.3, strokeWidth: 1 }} /> {avg > 0 && ( )} {hasPrev && ( )}
)}
{/* Live indicator */} {lastUpdatedAt != null && (
Live · {formatUpdatedAgo(lastUpdatedAt)}
)}
) }