From 033d735c3aaa407f12c15aae04284b792ca19b9c Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Mon, 9 Mar 2026 22:53:35 +0100 Subject: [PATCH] Replace dashboard BarChart with 21st.dev LineChart component Swap the main site dashboard chart from a bar chart to a line chart using 21st.dev's line-charts-6 component with dot grid background, glow shadow, and animated active dots. Add Badge trend indicators on metric cards using Phosphor icons. All existing features preserved (annotations, comparison, export, live indicator, interval controls). New UI primitives: line-charts-6, badge-2, card, button-1, avatar. Added shadcn-compatible CSS variables and Tailwind color mappings. Co-Authored-By: Claude Opus 4.6 --- components/dashboard/Chart.tsx | 598 +++++------ components/ui/avatar.tsx | 67 ++ components/ui/badge-2.tsx | 230 ++++ components/ui/button-1.tsx | 412 ++++++++ components/ui/card.tsx | 147 +++ components/ui/line-charts-6.tsx | 290 +++++ lib/utils.ts | 1 + package-lock.json | 1756 ++++++++++++++++++++++++++++++- package.json | 2 + styles/globals.css | 26 + tailwind.config.ts | 21 + 11 files changed, 3213 insertions(+), 337 deletions(-) create mode 100644 components/ui/avatar.tsx create mode 100644 components/ui/badge-2.tsx create mode 100644 components/ui/button-1.tsx create mode 100644 components/ui/card.tsx create mode 100644 components/ui/line-charts-6.tsx create mode 100644 lib/utils.ts 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 */}