diff --git a/components/dashboard/Chart.tsx b/components/dashboard/Chart.tsx index f6169f2..14ef827 100644 --- a/components/dashboard/Chart.tsx +++ b/components/dashboard/Chart.tsx @@ -2,34 +2,15 @@ import { useState, useMemo, useRef, useCallback, useEffect } from 'react' import { useTheme } from '@ciphera-net/ui' -import { - BarChart, - Bar, - XAxis, - CartesianGrid, - ReferenceLine, -} from 'recharts' -import { ChartContainer, ChartTooltip, type ChartConfig } from '@/components/charts' +import { Line, LineChart, XAxis, YAxis, ReferenceLine } from 'recharts' +import { ChartContainer, ChartTooltip, type ChartConfig } from '@/components/ui/line-charts-6' +import { Badge } from '@/components/ui/badge-2' +import { Card, CardContent, CardHeader } from '@/components/ui/card' import { formatNumber, formatDuration, formatUpdatedAgo, DatePicker } from '@ciphera-net/ui' -import { ArrowUpRightIcon, ArrowDownRightIcon, BarChartIcon, Select, DownloadIcon, PlusIcon, XIcon } from '@ciphera-net/ui' +import { Select, DownloadIcon, PlusIcon, XIcon } from '@ciphera-net/ui' import { Checkbox } from '@ciphera-net/ui' - -const COLORS = { - brand: 'var(--chart-1)', - success: 'var(--color-success)', - danger: 'var(--color-error)', -} - -const dashboardChartConfig = { - visitors: { label: 'Visitors', color: 'var(--chart-1)' }, - pageviews: { label: 'Pageviews', color: 'var(--chart-1)' }, - bounce_rate: { label: 'Bounce Rate', color: 'var(--chart-1)' }, - avg_duration: { label: 'Visit Duration', color: 'var(--chart-1)' }, - prevVisitors: { label: 'Previous', color: 'var(--chart-axis)' }, - prevPageviews: { label: 'Previous', color: 'var(--chart-axis)' }, - prevBounceRate: { label: 'Previous', color: 'var(--chart-axis)' }, - prevAvgDuration: { label: 'Previous', color: 'var(--chart-axis)' }, -} satisfies ChartConfig +import { ArrowUp, ArrowDown } from '@phosphor-icons/react' +import { cn } from '@/lib/utils' const ANNOTATION_COLORS: Record = { deploy: '#3b82f6', @@ -104,102 +85,54 @@ function formatEU(dateStr: string): string { return `${d}/${m}/${y}` } +// ─── Metric configurations ────────────────────────────────────────── -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() +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) }, +] - if (duration === 0) { - const prev = new Date(startDate.getTime() - 24 * 60 * 60 * 1000) - return prev.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) - } +const chartConfig = { + visitors: { label: 'Unique Visitors', color: '#FD5E0F' }, + pageviews: { label: 'Total Pageviews', color: '#3b82f6' }, + bounce_rate: { label: 'Bounce Rate', color: '#a855f7' }, + avg_duration: { label: 'Visit Duration', color: '#10b981' }, +} satisfies ChartConfig - 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)}` -} +// ─── Custom Tooltip ───────────────────────────────────────────────── -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 DashboardTooltipContent({ - active, - payload, - label, - metric, - metricLabel, - formatNumberFn, - showComparison, - prevPeriodLabel, -}: { +interface TooltipProps { active?: boolean - payload?: Array<{ payload: Record; value: number; dataKey?: string }> + payload?: Array<{ dataKey: string; value: number; color: string }> label?: string metric: MetricType - metricLabel: string - formatNumberFn: (n: number) => string - showComparison: boolean - prevPeriodLabel?: string -}) { - 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) +function CustomTooltip({ active, payload, metric }: TooltipProps) { + if (active && payload && payload.length) { + const entry = payload[0] + const config = METRIC_CONFIGS.find((m) => m.key === metric) - 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) : undefined, - }} - > - {delta > 0 ? '+' : ''}{delta}% - - )} + if (config) { + return ( +
+
+
+ {config.label}: + {config.format(entry.value)} +
- )} -
- ) + ) + } + } + return null } // ─── Chart Component ───────────────────────────────────────────────── @@ -224,9 +157,9 @@ export default function Chart({ onDeleteAnnotation, }: ChartProps) { const [metric, setMetric] = useState('visitors') - const [showComparison, setShowComparison] = useState(false) const chartContainerRef = useRef(null) const { resolvedTheme } = useTheme() + const [showComparison, setShowComparison] = useState(false) // ─── Annotation state ───────────────────────────────────────────── const [annotationForm, setAnnotationForm] = useState<{ @@ -268,9 +201,7 @@ export default function Chart({ // ─── Data ────────────────────────────────────────────────────────── - const chartData = data.map((item, i) => { - const prevItem = prevData?.[i] - + const chartData = data.map((item) => { let formattedDate: string if (interval === 'minute') { formattedDate = new Date(item.date).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }) @@ -291,10 +222,6 @@ export default function Chart({ 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, } }) @@ -369,228 +296,232 @@ export default function Chart({ } }, [annotationForm.editingId, onDeleteAnnotation]) - // ─── Metrics ─────────────────────────────────────────────────────── + // ─── Metrics with 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 metricsWithTrends = 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 - 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 }, - ] + return { + ...m, + value, + previousValue, + change, + isPositive, + } + }) - 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 hasPrev = !!(prevData?.length && showComparison) const hasData = data.length > 0 - const hasAnyNonZero = hasData && chartData.some((d) => (d[metric] as number) > 0) - - // ─── 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)}% - - ) - } + const hasAnyNonZero = hasData && chartData.some((d) => (d[metric] as number) > 0 + ) // ─── Render ──────────────────────────────────────────────────────── return ( -
- {/* Stat Cards */} -
- {metrics.map((item) => { - const isActive = metric === item.id - return ( - - ) - })} -
- - {/* Chart Area */} -
- {/* Toolbar */} -
- {/* Left: metric label + avg badge */} -
- - {metricLabel} - - {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} - - - - {canManageAnnotations && ( +
+ + + {/* Metrics Grid - 21st.dev style */} +
+ {metricsWithTrends.map((m) => ( - )} -
-
- - {!hasData ? ( -
- -

No data for this period

-
- ) : !hasAnyNonZero ? ( -
- -

No {metricLabel.toLowerCase()} recorded

-
- ) : ( -
- - - - - - } - /> - {hasPrev && ( - + key={m.key} + onClick={() => setMetric(m.key)} + className={cn( + 'cursor-pointer flex-1 text-start p-4 last:border-b-0 border-b @2xl:border-b @2xl:even:border-r @3xl:border-b-0 @3xl:border-r @3xl:last:border-r-0 border-neutral-200 dark:border-neutral-800 transition-all', + metric === m.key && 'bg-neutral-50 dark:bg-neutral-800/40', )} - - {annotationMarkers.map((marker) => { - const primaryCategory = marker.annotations[0].category - const color = ANNOTATION_COLORS[primaryCategory] || ANNOTATION_COLORS.other - return ( - - ) - })} - - + > +
+ {m.label} + {m.change !== null && ( + + {m.isPositive ? : } + {Math.abs(m.change).toFixed(1)}% + + )} +
+
{m.format(m.value)}
+ {m.previousValue != null && ( +
from {m.format(m.previousValue)}
+ )} + + ))}
- )} -
+ + + {/* 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 ? ( +
+

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

+
+ ) : ( +
+ + + + + + + + + + + + + + + + + { + const config = METRIC_CONFIGS.find((m) => m.key === metric) + return config ? config.format(value) : value.toString() + }} + /> + + } cursor={{ strokeDasharray: '3 3', stroke: '#9ca3af' }} /> + + {/* Background dot grid pattern */} + + + {/* Annotation reference lines */} + {annotationMarkers.map((marker) => { + const primaryCategory = marker.annotations[0].category + const color = ANNOTATION_COLORS[primaryCategory] || ANNOTATION_COLORS.other + return ( + + ) + })} + + + + +
+ )} +
+ + + {/* Annotation tags */} {annotationMarkers.length > 0 && ( -
+
Annotations: {annotationMarkers.map((marker) => { const primary = marker.annotations[0] @@ -617,7 +548,6 @@ export default function Chart({ {primary.text} {count > 1 && +{count - 1}} - {/* Hover tooltip */}
{marker.annotations.map((a) => ( @@ -641,7 +571,7 @@ export default function Chart({ {/* Live indicator */} {lastUpdatedAt != null && ( -
+
@@ -692,7 +622,6 @@ export default function Chart({ {annotationForm.editingId ? 'Edit annotation' : 'Add annotation'}
- {/* Date picker trigger */}
- {/* Time input */}
- {/* Note */}
{annotationForm.text.length}/200
- {/* Category - custom Select */}