'use client' import { useState, useMemo, useRef, useCallback, useEffect } from 'react' import { useTheme } from '@ciphera-net/ui' import { AreaChart as VisxAreaChart, Area as VisxArea, Grid as VisxGrid, XAxis as VisxXAxis, YAxis as VisxYAxis, ChartTooltip as VisxChartTooltip, type TooltipRow } from '@/components/ui/area-chart' import { Card, CardContent, CardHeader } from '@/components/ui/card' import { formatNumber, formatDuration, formatUpdatedAgo, DatePicker } from '@ciphera-net/ui' import { Select, DownloadIcon, PlusIcon, XIcon } from '@ciphera-net/ui' import { Checkbox } from '@ciphera-net/ui' import { ArrowUpRight, ArrowDownRight } from '@phosphor-icons/react' import { motion } from 'framer-motion' import { AnimatedNumber } from '@/components/ui/animated-number' import { cn } from '@/lib/utils' import { formatTime, formatDateShort, formatDate } from '@/lib/utils/formatDate' const ANNOTATION_COLORS: Record = { deploy: '#3b82f6', campaign: '#22c55e', incident: '#ef4444', other: '#a3a3a3', } const MAX_VISIBLE_ANNOTATIONS = 20 const ANNOTATION_LABELS: Record = { deploy: 'Deploy', campaign: 'Campaign', incident: 'Incident', other: 'Note', } const CATEGORY_OPTIONS = [ { value: 'deploy', label: 'Deploy' }, { value: 'campaign', label: 'Campaign' }, { value: 'incident', label: 'Incident' }, { value: 'other', label: 'Other' }, ] interface AnnotationData { id: string date: string time?: string | null text: string category: string } 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 } period?: string todayInterval: 'minute' | 'hour' setTodayInterval: (interval: 'minute' | 'hour') => void multiDayInterval: 'hour' | 'day' setMultiDayInterval: (interval: 'hour' | 'day') => void onExportChart?: () => void lastUpdatedAt?: number | null annotations?: AnnotationData[] canManageAnnotations?: boolean onCreateAnnotation?: (data: { date: string; time?: string; text: string; category: string }) => Promise onUpdateAnnotation?: (id: string, data: { date: string; time?: string; text: string; category: string }) => Promise onDeleteAnnotation?: (id: string) => Promise } type MetricType = 'pageviews' | 'visitors' | 'bounce_rate' | 'avg_duration' // ─── Helpers ───────────────────────────────────────────────────────── function formatEU(dateStr: string): string { return formatDate(new Date(dateStr + 'T00:00:00')) } // ─── Metric configurations ────────────────────────────────────────── const METRIC_CONFIGS: { key: MetricType label: string format: (v: number) => string isNegative?: boolean }[] = [ { key: 'visitors', label: 'Unique Visitors', format: (v) => formatNumber(v) }, { key: 'pageviews', label: 'Total Pageviews', format: (v) => formatNumber(v) }, { key: 'bounce_rate', label: 'Bounce Rate', format: (v) => `${Math.round(v)}%`, isNegative: true }, { key: 'avg_duration', label: 'Visit Duration', format: (v) => formatDuration(v) }, ] const CHART_COLORS: Record = { visitors: '#FD5E0F', pageviews: '#FD5E0F', bounce_rate: '#FD5E0F', avg_duration: '#FD5E0F', } // ─── Chart Component ───────────────────────────────────────────────── export default function Chart({ data, prevData, stats, prevStats, interval, dateRange, period, todayInterval, setTodayInterval, multiDayInterval, setMultiDayInterval, onExportChart, lastUpdatedAt, annotations, canManageAnnotations, onCreateAnnotation, onUpdateAnnotation, onDeleteAnnotation, }: ChartProps) { const [metric, setMetric] = useState('visitors') const chartContainerRef = useRef(null) const { resolvedTheme } = useTheme() const [showComparison, setShowComparison] = useState(false) // Tick every 1s so "Live · Xs ago" counts in real time (scoped to Chart only) const [, setTick] = useState(0) useEffect(() => { if (lastUpdatedAt == null) return const timer = setInterval(() => setTick((t) => t + 1), 1000) return () => clearInterval(timer) }, [lastUpdatedAt]) // ─── Annotation state ───────────────────────────────────────────── const [annotationForm, setAnnotationForm] = useState<{ visible: boolean; editingId?: string; date: string; time: string; text: string; category: string }>({ visible: false, date: new Date().toISOString().slice(0, 10), time: '', text: '', category: 'other' }) const [calendarOpen, setCalendarOpen] = useState(false) const [saving, setSaving] = useState(false) const [contextMenu, setContextMenu] = useState<{ x: number; y: number; date: string } | null>(null) // Close context menu and annotation form on Escape useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { if (calendarOpen) { setCalendarOpen(false); return } if (contextMenu) { setContextMenu(null); return } if (annotationForm.visible) { setAnnotationForm(f => ({ ...f, visible: false })); return } } } document.addEventListener('keydown', handleKeyDown) return () => document.removeEventListener('keydown', handleKeyDown) }, [calendarOpen, contextMenu, annotationForm.visible]) const handleExportChart = useCallback(async () => { if (onExportChart) { onExportChart(); return } if (!chartContainerRef.current) return try { const { toPng } = await import('html-to-image') const bg = getComputedStyle(chartContainerRef.current).backgroundColor || (resolvedTheme === 'dark' ? '#171717' : '#ffffff') const dataUrl = await toPng(chartContainerRef.current, { cacheBust: true, backgroundColor: bg, }) const link = document.createElement('a') link.download = `chart-${dateRange.start}-${dateRange.end}.png` link.href = dataUrl link.click() } catch { /* noop */ } }, [onExportChart, dateRange, resolvedTheme]) // ─── Data ────────────────────────────────────────────────────────── const chartData = useMemo(() => data.map((item) => { let formattedDate: string if (interval === 'minute') { formattedDate = formatTime(new Date(item.date)) } else if (interval === 'hour') { const d = new Date(item.date) formattedDate = formatDateShort(d) + ', ' + formatTime(d) } else { formattedDate = formatDateShort(new Date(item.date)) } return { date: formattedDate, dateObj: new Date(item.date), originalDate: item.date, pageviews: item.pageviews, visitors: item.visitors, bounce_rate: item.bounce_rate, avg_duration: item.avg_duration, } }), [data, interval]) const annotationMarkers = useMemo(() => { if (!annotations?.length) return [] const byDate = new Map() for (const a of annotations) { const existing = byDate.get(a.date) || [] existing.push(a) byDate.set(a.date, existing) } const markers: { x: string; annotations: AnnotationData[] }[] = [] for (const [date, anns] of byDate) { const match = chartData.find((d) => { const orig = d.originalDate return orig.startsWith(date) || orig === date }) if (match) { markers.push({ x: match.date, annotations: anns }) } } return markers }, [annotations, chartData]) const visibleAnnotationMarkers = annotationMarkers.slice(0, MAX_VISIBLE_ANNOTATIONS) const hiddenAnnotationCount = Math.max(0, annotationMarkers.length - MAX_VISIBLE_ANNOTATIONS) // ─── Right-click handler ────────────────────────────────────────── const handleChartContextMenu = useCallback((e: React.MouseEvent) => { if (!canManageAnnotations) return e.preventDefault() const rect = e.currentTarget.getBoundingClientRect() const relX = e.clientX - rect.left const leftMargin = 48 const rightMargin = 16 const plotWidth = rect.width - leftMargin - rightMargin const fraction = Math.max(0, Math.min(1, (relX - leftMargin) / plotWidth)) const index = Math.min(Math.round(fraction * (chartData.length - 1)), chartData.length - 1) const point = chartData[index] if (point) { setContextMenu({ x: e.clientX, y: e.clientY, date: point.originalDate.slice(0, 10) }) } }, [canManageAnnotations, chartData]) // ─── Annotation form handlers ───────────────────────────────────── const handleSaveAnnotation = useCallback(async () => { if (saving) return const payload = { date: annotationForm.date, time: annotationForm.time || undefined, text: annotationForm.text.trim(), category: annotationForm.category, } setSaving(true) try { if (annotationForm.editingId && onUpdateAnnotation) { await onUpdateAnnotation(annotationForm.editingId, payload) } else if (onCreateAnnotation) { await onCreateAnnotation(payload) } setAnnotationForm({ visible: false, date: '', time: '', text: '', category: 'other' }) } finally { setSaving(false) } }, [annotationForm, saving, onCreateAnnotation, onUpdateAnnotation]) const handleDeleteAnnotation = useCallback(async () => { if (!annotationForm.editingId || !onDeleteAnnotation) return setSaving(true) try { await onDeleteAnnotation(annotationForm.editingId) setAnnotationForm({ visible: false, date: '', time: '', text: '', category: 'other' }) } finally { setSaving(false) } }, [annotationForm.editingId, onDeleteAnnotation]) // ─── Metrics with trends ────────────────────────────────────────── const metricsWithTrends = useMemo(() => METRIC_CONFIGS.map((m) => { const value = stats[m.key] const previousValue = prevStats?.[m.key] const change = previousValue != null && previousValue > 0 ? ((value - previousValue) / previousValue) * 100 : null const isPositive = change !== null ? (m.isNegative ? change < 0 : change > 0) : null return { ...m, value, previousValue, change, isPositive, } }), [stats, prevStats]) const hasData = data.length > 0 const hasAnyNonZero = hasData && chartData.some((d) => (d[metric] as number) > 0 ) // ─── Render ──────────────────────────────────────────────────────── return (
{/* Metrics Grid - 21st.dev style */}
{metricsWithTrends.map((m) => ( ))}
{/* Toolbar */}
{METRIC_CONFIGS.find((m) => m.key === metric)?.label}
{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} {canManageAnnotations && ( )}
{!hasData || !hasAnyNonZero ? (
No data available

{!hasData ? 'No data for this period' : `No ${METRIC_CONFIGS.find((m) => m.key === metric)?.label.toLowerCase()} recorded`}

) : (
[]} xDataKey="dateObj" aspectRatio="2.5 / 1" margin={{ top: 20, right: 20, bottom: 40, left: 50 }} animationDuration={400} > `${String(d.getUTCHours()).padStart(2, '0')}:${String(d.getUTCMinutes()).padStart(2, '0')}` : (d) => d.toLocaleDateString('en-GB', { day: 'numeric', month: 'short' }) } /> { const config = METRIC_CONFIGS.find((m) => m.key === metric) return config ? config.format(v) : v.toString() }} /> { const dateObj = point.dateObj instanceof Date ? point.dateObj : new Date(point.dateObj as string || Date.now()) const config = METRIC_CONFIGS.find((m) => m.key === metric) const value = point[metric] as number const title = interval === 'minute' || interval === 'hour' ? `${String(dateObj.getUTCHours()).padStart(2, '0')}:${String(dateObj.getUTCMinutes()).padStart(2, '0')}` : dateObj.toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short' }) return (
{title}
{config?.label || metric}
{config ? config.format(value) : value}
) }} />
)}
{/* Footer: Annotations + Live indicator on same row */} {(annotationMarkers.length > 0 || lastUpdatedAt != null) && (
{/* Annotations left */}
{annotationMarkers.length > 0 && ( <> Annotations: {visibleAnnotationMarkers.map((marker) => { const primary = marker.annotations[0] const color = ANNOTATION_COLORS[primary.category] || ANNOTATION_COLORS.other const count = marker.annotations.length return ( ) })} {hiddenAnnotationCount > 0 && ( +{hiddenAnnotationCount} more )} )}
)}
{/* ─── Right-click Context Menu ──────────────────────────────── */} {contextMenu && ( <>
setContextMenu(null)} onContextMenu={(e) => { e.preventDefault(); setContextMenu(null) }} />
)} {/* ─── Annotation Form Modal ─────────────────────────────────── */} {annotationForm.visible && (

{annotationForm.editingId ? 'Edit annotation' : 'Add annotation'}

setAnnotationForm((f) => ({ ...f, time: e.target.value }))} className="flex-1 px-3 py-1.5 text-sm rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 text-white focus:outline-none focus:ring-2 focus:ring-brand-orange/30" /> {annotationForm.time && ( )}
setAnnotationForm((f) => ({ ...f, text: e.target.value.slice(0, 200) }))} placeholder="e.g. Launched new homepage" maxLength={200} className="w-full px-3 py-1.5 text-sm rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 text-white focus:outline-none focus:ring-2 focus:ring-brand-orange/30" autoFocus /> {annotationForm.text.length}/200