From 641a3deebbd03d7e3d388969f3a66436cdb5dd65 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sat, 7 Mar 2026 00:31:05 +0100 Subject: [PATCH] refactor: rebuild Chart component from scratch - Remove sparklines from stat cards (redundant with main chart) - Widen Y-axis to 40px, add allowDecimals=false for count metrics - Move avg label from inside chart to toolbar badge - Lighter grid lines, simpler gradient, thinner strokes - Streamline toolbar: inline controls, icon-only export, no trailing separator - Move live indicator from absolute to proper flow element - Cleaner empty states without dashed border boxes - Extract TrendBadge component, add tabular-nums for aligned numbers --- CHANGELOG.md | 4 +- components/dashboard/Chart.tsx | 628 ++++++++++++++------------------- 2 files changed, 273 insertions(+), 359 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ad3611..0275451 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - **Underline tab switchers.** Content, Locations, and Technology panels now use minimal underline tabs instead of pill-style switchers. - **"View all" moved to bottom of list.** The expand button on each panel is now a subtle "View all ›" link at the bottom of the data list instead of an icon in the header. - **Filter button uses filter icon.** The filter dropdown button now shows a funnel icon instead of a plus sign. +- **Chart component rebuilt from scratch.** Cleaner stat cards without sparklines, wider Y-axis that no longer clips labels, integer-only ticks for visitor/pageview counts, lighter grid lines, average moved to a subtle toolbar badge instead of overlapping the chart, streamlined toolbar with inline controls and icon-only export, and a properly positioned live indicator. ### Fixed @@ -28,7 +29,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - **Campaigns now respect your active filters.** Previously, the Campaigns panel ignored dashboard filters and always showed all campaigns. Now it filters along with everything else. - **Duplicate "Direct" entry removed from the referrer filter list.** The referrer suggestions no longer show "Direct" twice. - **Filter dropdowns now show all your data.** Previously, the filter value list only showed up to 10 items — so if you had 50 cities or 30 browsers, most were missing. Now up to 100 values are loaded when you open a filter, with a loading spinner while they're fetched. -- **Chart average label no longer shows excessive decimals.** The "Avg" line label now displays one decimal place instead of raw floating-point numbers. +- **Chart Y-axis no longer shows fractional visitors.** Count metrics (visitors, pageviews) now use integer-only ticks — no more "0.75 visitors". +- **Chart average label no longer shows excessive decimals.** The average is now shown as a rounded value in a toolbar badge instead of a floating label inside the chart. ## [0.13.0-alpha] - 2026-03-02 diff --git a/components/dashboard/Chart.tsx b/components/dashboard/Chart.tsx index ae1ce06..48abf8f 100644 --- a/components/dashboard/Chart.tsx +++ b/components/dashboard/Chart.tsx @@ -14,8 +14,7 @@ import { } from 'recharts' import type { TooltipProps } from 'recharts' import { formatNumber, formatDuration, formatUpdatedAgo } from '@ciphera-net/ui' -import Sparkline from './Sparkline' -import { ArrowUpRightIcon, ArrowDownRightIcon, BarChartIcon, Select, Button, DownloadIcon } from '@ciphera-net/ui' +import { ArrowUpRightIcon, ArrowDownRightIcon, BarChartIcon, Select, DownloadIcon } from '@ciphera-net/ui' import { Checkbox } from '@ciphera-net/ui' const COLORS = { @@ -26,6 +25,7 @@ const COLORS = { 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)', @@ -35,6 +35,7 @@ const CHART_COLORS_LIGHT = { 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)', @@ -68,103 +69,14 @@ interface ChartProps { 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; - } +// ─── Helpers ───────────────────────────────────────────────────────── - 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).toFixed(1)}M` if (value >= 1000) return `${+(value / 1000).toFixed(1)}k` @@ -172,16 +84,21 @@ function formatAxisValue(value: number): string { 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` + 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 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) @@ -198,7 +115,6 @@ function getPrevDateRangeLabel(dateRange: { start: string; end: string }): strin 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) @@ -210,11 +126,88 @@ function getTrendContext(dateRange: { start: string; end: string }): string { return `vs previous ${days} days` } -export default function Chart({ - data, - prevData, - stats, - prevStats, +// ─── 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, @@ -230,10 +223,7 @@ export default function Chart({ const { resolvedTheme } = useTheme() const handleExportChart = useCallback(async () => { - if (onExportChart) { - onExportChart() - return - } + if (onExportChart) { onExportChart(); return } if (!chartContainerRef.current) return try { const { toPng } = await import('html-to-image') @@ -245,9 +235,7 @@ export default function Chart({ link.download = `chart-${dateRange.start}-${dateRange.end}.png` link.href = dataUrl link.click() - } catch { - // Fallback: do nothing if export fails - } + } catch { /* noop */ } }, [onExportChart, dateRange, resolvedTheme]) const colors = useMemo( @@ -255,27 +243,24 @@ export default function Chart({ [resolvedTheme] ) - // * Align current and previous data + // ─── 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, @@ -290,7 +275,8 @@ export default function Chart({ } }) - // * Calculate trends + // ─── Metrics ─────────────────────────────────────────────────────── + const calculateTrend = (current: number, previous?: number) => { if (!previous) return null if (previous === 0) return current > 0 ? 100 : 0 @@ -298,282 +284,210 @@ export default function Chart({ } 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 + { 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 chartMetric = metric - const metricLabel = metrics.find(m => m.id === metric)?.label || 'visitors' + const metricLabel = activeMetric.label 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 + ? 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[chartMetric] as number) > 0) + const hasAnyNonZero = hasData && chartData.some((d) => (d[metric] 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 + // 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 - // * 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 + // ─── 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 (
- {/* * Subtle live/updated indicator in bottom-right corner */} - {lastUpdatedAt != null && ( -
- - - - - Live · {formatUpdatedAgo(lastUpdatedAt)} -
- )} - {/* Stats Header (Interactive Tabs) */} + {/* Stat Cards */}
- {metrics.map((item) => ( - - ))} +
+ + {item.value} + + +
+

{trendContext}

+ {isActive && ( +
+ )} + + ) + })}
{/* Chart Area */} -
- {/* Toolbar Row */} -
- {/* Left side: Legend */} -
+
+ {/* Toolbar */} +
+ {/* Left: metric label + avg badge */} +
+ + {metricLabel} + + {hasAnyNonZero && avg > 0 && ( + + Avg: {formatAvgLabel(avg, metric)} + + )} {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]" - /> - )} -
+ {/* 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 ? ( -
- - {showComparison && prevPeriodLabel && ( - - ({prevPeriodLabel}) - - )} -
+ ) : null} - - - {/* Vertical Separator */} -
+
{!hasData ? ( -
- -

- No data for this period -

-

Try a different date range

+
+ +

No data for this period

) : !hasAnyNonZero ? ( -
- -

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

-

Try selecting another metric or date range

+
+ +

No {metricLabel.toLowerCase()} recorded

) : ( -
-
- {metricLabel} -
-
- - +
+ + - - - + + - + { if (metric === 'bounce_rate') return `${val}%` if (metric === 'avg_duration') return formatAxisDuration(val) @@ -584,12 +498,9 @@ export default function Chart({ content={(p: TooltipProps) => ( } + payload={p.payload as Array<{ payload: Record; value: number; dataKey?: string }>} label={p.label as string} - metric={chartMetric} + metric={metric} metricLabel={metricLabel} formatNumberFn={formatNumber} showComparison={hasPrev} @@ -597,7 +508,7 @@ export default function Chart({ colors={colors} /> )} - cursor={{ stroke: activeMetric.color, strokeDasharray: '4 4', strokeWidth: 1 }} + cursor={{ stroke: colors.axis, strokeOpacity: 0.3, strokeWidth: 1 }} /> {avg > 0 && ( @@ -605,34 +516,23 @@ export default function Chart({ y={avg} stroke={colors.axis} strokeDasharray="4 4" - strokeOpacity={0.7} - label={{ - value: `Avg: ${metric === 'bounce_rate' ? `${Math.round(avg)}%` : metric === 'avg_duration' ? formatAxisDuration(avg) : formatAxisValue(avg)}`, - position: 'insideTopRight', - fill: colors.axis, - fontSize: 11, - }} + strokeOpacity={0.4} /> )} {hasPrev && ( )} @@ -640,30 +540,42 @@ export default function Chart({ - - -
+
+
)}
+ + {/* Live indicator */} + {lastUpdatedAt != null && ( +
+
+ + + + + Live · {formatUpdatedAgo(lastUpdatedAt)} +
+
+ )}
) }