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
This commit is contained in:
Usman Baig
2026-03-07 00:31:05 +01:00
parent 77dc61e7d0
commit 641a3deebb
2 changed files with 273 additions and 359 deletions

View File

@@ -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. - **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. - **"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. - **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 ### 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. - **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. - **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. - **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 ## [0.13.0-alpha] - 2026-03-02

View File

@@ -14,8 +14,7 @@ import {
} from 'recharts' } from 'recharts'
import type { TooltipProps } from 'recharts' import type { TooltipProps } from 'recharts'
import { formatNumber, formatDuration, formatUpdatedAgo } from '@ciphera-net/ui' import { formatNumber, formatDuration, formatUpdatedAgo } from '@ciphera-net/ui'
import Sparkline from './Sparkline' import { ArrowUpRightIcon, ArrowDownRightIcon, BarChartIcon, Select, DownloadIcon } from '@ciphera-net/ui'
import { ArrowUpRightIcon, ArrowDownRightIcon, BarChartIcon, Select, Button, DownloadIcon } from '@ciphera-net/ui'
import { Checkbox } from '@ciphera-net/ui' import { Checkbox } from '@ciphera-net/ui'
const COLORS = { const COLORS = {
@@ -26,6 +25,7 @@ const COLORS = {
const CHART_COLORS_LIGHT = { const CHART_COLORS_LIGHT = {
border: 'var(--color-neutral-200)', border: 'var(--color-neutral-200)',
grid: 'var(--color-neutral-100)',
text: 'var(--color-neutral-900)', text: 'var(--color-neutral-900)',
textMuted: 'var(--color-neutral-500)', textMuted: 'var(--color-neutral-500)',
axis: 'var(--color-neutral-400)', axis: 'var(--color-neutral-400)',
@@ -35,6 +35,7 @@ const CHART_COLORS_LIGHT = {
const CHART_COLORS_DARK = { const CHART_COLORS_DARK = {
border: 'var(--color-neutral-700)', border: 'var(--color-neutral-700)',
grid: 'var(--color-neutral-800)',
text: 'var(--color-neutral-50)', text: 'var(--color-neutral-50)',
textMuted: 'var(--color-neutral-400)', textMuted: 'var(--color-neutral-400)',
axis: 'var(--color-neutral-500)', axis: 'var(--color-neutral-500)',
@@ -68,103 +69,14 @@ interface ChartProps {
setTodayInterval: (interval: 'minute' | 'hour') => void setTodayInterval: (interval: 'minute' | 'hour') => void
multiDayInterval: 'hour' | 'day' multiDayInterval: 'hour' | 'day'
setMultiDayInterval: (interval: 'hour' | 'day') => void setMultiDayInterval: (interval: 'hour' | 'day') => void
/** Optional: callback when user requests chart export (parent can open ExportModal or handle export) */
onExportChart?: () => void onExportChart?: () => void
/** Optional: timestamp of last data fetch for "Live · Xs ago" indicator */
lastUpdatedAt?: number | null lastUpdatedAt?: number | null
} }
type MetricType = 'pageviews' | 'visitors' | 'bounce_rate' | 'avg_duration' type MetricType = 'pageviews' | 'visitors' | 'bounce_rate' | 'avg_duration'
// * Custom tooltip with comparison and theme-aware styling // ─── Helpers ─────────────────────────────────────────────────────────
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<string, number>)?.[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;
}
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 (
<div
className="rounded-lg border px-4 py-3 shadow-lg transition-shadow duration-300"
style={{
backgroundColor: colors.tooltipBg,
borderColor: colors.tooltipBorder,
}}
>
<div className="text-xs font-medium" style={{ color: colors.textMuted, marginBottom: 6 }}>
{label}
</div>
<div className="flex items-baseline gap-2">
<span className="text-base font-bold" style={{ color: colors.text }}>
{formatValue(value)}
</span>
<span className="text-xs" style={{ color: colors.textMuted }}>
{metricLabel}
</span>
</div>
{hasPrev && (
<div className="mt-1.5 flex items-center gap-2 text-xs" style={{ color: colors.textMuted }}>
<span>vs {formatValue(prev as number)} {prevPeriodLabel ? `(${prevPeriodLabel})` : 'prev'}</span>
{delta !== null && (
<span
className="font-medium"
style={{
color: delta > 0 ? (metric === 'bounce_rate' ? COLORS.danger : COLORS.success) : delta < 0 ? (metric === 'bounce_rate' ? COLORS.success : COLORS.danger) : colors.textMuted,
}}
>
{delta > 0 ? '+' : ''}{delta}%
</span>
)}
</div>
)}
</div>
)
}
// * Compact Y-axis formatter: 1.5M, 12k, 99
function formatAxisValue(value: number): string { function formatAxisValue(value: number): string {
if (value >= 1e6) return `${+(value / 1e6).toFixed(1)}M` if (value >= 1e6) return `${+(value / 1e6).toFixed(1)}M`
if (value >= 1000) return `${+(value / 1000).toFixed(1)}k` if (value >= 1000) return `${+(value / 1000).toFixed(1)}k`
@@ -172,16 +84,21 @@ function formatAxisValue(value: number): string {
return String(value) return String(value)
} }
// * Compact duration for Y-axis ticks (avoids truncation: "5m" not "5m 0s")
function formatAxisDuration(seconds: number): string { function formatAxisDuration(seconds: number): string {
if (!seconds) return '0s' if (!seconds) return '0s'
const m = Math.floor(seconds / 60) const m = Math.floor(seconds / 60)
const s = 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` 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 { function getPrevDateRangeLabel(dateRange: { start: string; end: string }): string {
const startDate = new Date(dateRange.start) const startDate = new Date(dateRange.start)
const endDate = new Date(dateRange.end) const endDate = new Date(dateRange.end)
@@ -198,7 +115,6 @@ function getPrevDateRangeLabel(dateRange: { start: string; end: string }): strin
return `${fmt(prevStart)} ${fmt(prevEnd)}` 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 { function getTrendContext(dateRange: { start: string; end: string }): string {
const startDate = new Date(dateRange.start) const startDate = new Date(dateRange.start)
const endDate = new Date(dateRange.end) const endDate = new Date(dateRange.end)
@@ -210,11 +126,88 @@ function getTrendContext(dateRange: { start: string; end: string }): string {
return `vs previous ${days} days` return `vs previous ${days} days`
} }
export default function Chart({ // ─── Tooltip ─────────────────────────────────────────────────────────
data,
prevData, function ChartTooltip({
stats, active,
prevStats, payload,
label,
metric,
metricLabel,
formatNumberFn,
showComparison,
prevPeriodLabel,
colors,
}: {
active?: boolean
payload?: Array<{ payload: Record<string, number>; 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 (
<div
className="rounded-lg border px-3.5 py-2.5 shadow-lg"
style={{ backgroundColor: colors.tooltipBg, borderColor: colors.tooltipBorder }}
>
<div className="text-[11px] font-medium mb-1" style={{ color: colors.textMuted }}>
{label}
</div>
<div className="flex items-baseline gap-1.5">
<span className="text-sm font-bold" style={{ color: colors.text }}>
{formatValue(value)}
</span>
<span className="text-[11px]" style={{ color: colors.textMuted }}>
{metricLabel}
</span>
</div>
{hasPrev && (
<div className="mt-1 flex items-center gap-1.5 text-[11px]" style={{ color: colors.textMuted }}>
<span>vs {formatValue(prev)} {prevPeriodLabel ? `(${prevPeriodLabel})` : ''}</span>
{delta !== null && (
<span
className="font-medium"
style={{
color: delta > 0 ? (metric === 'bounce_rate' ? COLORS.danger : COLORS.success) : delta < 0 ? (metric === 'bounce_rate' ? COLORS.success : COLORS.danger) : colors.textMuted,
}}
>
{delta > 0 ? '+' : ''}{delta}%
</span>
)}
</div>
)}
</div>
)
}
// ─── Chart Component ─────────────────────────────────────────────────
export default function Chart({
data,
prevData,
stats,
prevStats,
interval, interval,
dateRange, dateRange,
todayInterval, todayInterval,
@@ -230,10 +223,7 @@ export default function Chart({
const { resolvedTheme } = useTheme() const { resolvedTheme } = useTheme()
const handleExportChart = useCallback(async () => { const handleExportChart = useCallback(async () => {
if (onExportChart) { if (onExportChart) { onExportChart(); return }
onExportChart()
return
}
if (!chartContainerRef.current) return if (!chartContainerRef.current) return
try { try {
const { toPng } = await import('html-to-image') const { toPng } = await import('html-to-image')
@@ -245,9 +235,7 @@ export default function Chart({
link.download = `chart-${dateRange.start}-${dateRange.end}.png` link.download = `chart-${dateRange.start}-${dateRange.end}.png`
link.href = dataUrl link.href = dataUrl
link.click() link.click()
} catch { } catch { /* noop */ }
// Fallback: do nothing if export fails
}
}, [onExportChart, dateRange, resolvedTheme]) }, [onExportChart, dateRange, resolvedTheme])
const colors = useMemo( const colors = useMemo(
@@ -255,27 +243,24 @@ export default function Chart({
[resolvedTheme] [resolvedTheme]
) )
// * Align current and previous data // ─── Data ──────────────────────────────────────────────────────────
const chartData = data.map((item, i) => { 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] const prevItem = prevData?.[i]
// * Format date based on interval
let formattedDate: string let formattedDate: string
if (interval === 'minute') { if (interval === 'minute') {
formattedDate = new Date(item.date).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }) formattedDate = new Date(item.date).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })
} else if (interval === 'hour') { } else if (interval === 'hour') {
const d = new Date(item.date) const d = new Date(item.date)
const isMidnight = d.getHours() === 0 && d.getMinutes() === 0 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 formattedDate = isMidnight
? d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) + ' 12:00 AM' ? 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' }) : d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) + ', ' + d.toLocaleTimeString('en-US', { hour: 'numeric', minute: 'numeric' })
} else { } else {
formattedDate = new Date(item.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) formattedDate = new Date(item.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
} }
return { return {
date: formattedDate, date: formattedDate,
originalDate: item.date, originalDate: item.date,
@@ -290,7 +275,8 @@ export default function Chart({
} }
}) })
// * Calculate trends // ─── Metrics ───────────────────────────────────────────────────────
const calculateTrend = (current: number, previous?: number) => { const calculateTrend = (current: number, previous?: number) => {
if (!previous) return null if (!previous) return null
if (previous === 0) return current > 0 ? 100 : 0 if (previous === 0) return current > 0 ? 100 : 0
@@ -298,282 +284,210 @@ export default function Chart({
} }
const metrics = [ const metrics = [
{ { id: 'visitors' as const, label: 'Unique Visitors', value: formatNumber(stats.visitors), trend: calculateTrend(stats.visitors, prevStats?.visitors), invertTrend: false },
id: 'visitors', { id: 'pageviews' as const, label: 'Total Pageviews', value: formatNumber(stats.pageviews), trend: calculateTrend(stats.pageviews, prevStats?.pageviews), invertTrend: false },
label: 'Unique Visitors', { id: 'bounce_rate' as const, label: 'Bounce Rate', value: `${Math.round(stats.bounce_rate)}%`, trend: calculateTrend(stats.bounce_rate, prevStats?.bounce_rate), invertTrend: true },
value: formatNumber(stats.visitors), { id: 'avg_duration' as const, label: 'Visit Duration', value: formatDuration(stats.avg_duration), trend: calculateTrend(stats.avg_duration, prevStats?.avg_duration), invertTrend: false },
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
const activeMetric = metrics.find((m) => m.id === metric) || metrics[0] const activeMetric = metrics.find((m) => m.id === metric) || metrics[0]
const chartMetric = metric const metricLabel = activeMetric.label
const metricLabel = metrics.find(m => m.id === metric)?.label || 'visitors'
const prevPeriodLabel = prevData?.length ? getPrevDateRangeLabel(dateRange) : '' const prevPeriodLabel = prevData?.length ? getPrevDateRangeLabel(dateRange) : ''
const trendContext = getTrendContext(dateRange) const trendContext = getTrendContext(dateRange)
const avg = chartData.length 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 : 0
const hasPrev = !!(prevData?.length && showComparison) const hasPrev = !!(prevData?.length && showComparison)
const hasData = data.length > 0 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). // Count metrics should never show decimal Y-axis ticks
const midnightTicks = const isCountMetric = metric === 'visitors' || metric === 'pageviews'
interval === 'hour'
? (() => { // ─── X-Axis Ticks ─────────────────────────────────────────────────
const t = chartData
.filter((_, i) => { const midnightTicks = interval === 'hour'
const d = new Date(data[i].date) ? (() => {
return d.getHours() === 0 && d.getMinutes() === 0 const t = chartData
}) .filter((_, i) => { const d = new Date(data[i].date); return d.getHours() === 0 && d.getMinutes() === 0 })
.map((c) => c.date) .map((c) => c.date)
return t.length > 0 ? t : undefined return t.length > 0 ? t : undefined
})() })()
: 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 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 <span className="text-neutral-400 dark:text-neutral-500"></span>
const effective = invert ? -trend : trend
const isPositive = effective > 0
const isNegative = effective < 0
return (
<span className={`inline-flex items-center text-xs font-medium ${isPositive ? 'text-emerald-600 dark:text-emerald-400' : isNegative ? 'text-red-500 dark:text-red-400' : 'text-neutral-400'}`}>
{isPositive ? <ArrowUpRightIcon className="w-3 h-3 mr-0.5" /> : isNegative ? <ArrowDownRightIcon className="w-3 h-3 mr-0.5" /> : null}
{Math.abs(trend)}%
</span>
)
}
// ─── Render ────────────────────────────────────────────────────────
return ( return (
<div <div
ref={chartContainerRef} ref={chartContainerRef}
className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden shadow-sm relative" className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden relative"
role="region" role="region"
aria-label={`Analytics chart showing ${metricLabel} over time`} aria-label={`Analytics chart showing ${metricLabel} over time`}
> >
{/* * Subtle live/updated indicator in bottom-right corner */} {/* Stat Cards */}
{lastUpdatedAt != null && (
<div
className="absolute bottom-3 right-6 flex items-center gap-2 text-xs text-neutral-500 dark:text-neutral-400 pointer-events-none"
title="Data refreshes every 30 seconds"
>
<span className="relative flex h-1.5 w-1.5">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-500 opacity-75" />
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-green-500" />
</span>
Live · {formatUpdatedAgo(lastUpdatedAt)}
</div>
)}
{/* Stats Header (Interactive Tabs) */}
<div className="grid grid-cols-2 md:grid-cols-4 divide-x divide-neutral-200 dark:divide-neutral-800 border-b border-neutral-200 dark:border-neutral-800"> <div className="grid grid-cols-2 md:grid-cols-4 divide-x divide-neutral-200 dark:divide-neutral-800 border-b border-neutral-200 dark:border-neutral-800">
{metrics.map((item) => ( {metrics.map((item) => {
<button const isActive = metric === item.id
key={item.id} return (
type="button" <button
onClick={() => setMetric(item.id as MetricType)} key={item.id}
aria-pressed={metric === item.id} type="button"
aria-label={`Show ${item.label} chart`} onClick={() => setMetric(item.id)}
className={` aria-pressed={isActive}
p-4 sm:p-6 text-left transition-colors relative group aria-label={`Show ${item.label} chart`}
hover:bg-neutral-50 dark:hover:bg-neutral-800/50 className={`p-4 sm:px-6 sm:py-5 text-left transition-colors relative cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-orange/50 ${isActive ? 'bg-neutral-50 dark:bg-neutral-800/40' : 'hover:bg-neutral-50/50 dark:hover:bg-neutral-800/20'}`}
${metric === item.id ? 'bg-neutral-50 dark:bg-neutral-800/50' : ''} >
cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-orange-500 focus-visible:ring-offset-2 <div className={`text-[11px] font-semibold uppercase tracking-wider mb-1.5 ${isActive ? 'text-neutral-900 dark:text-white' : 'text-neutral-400 dark:text-neutral-500'}`}>
`} {item.label}
>
<div className={`text-xs font-semibold uppercase tracking-wider mb-1 flex items-center gap-2 ${metric === item.id ? 'text-neutral-900 dark:text-white' : 'text-neutral-500'}`}>
{item.label}
</div>
<div className="flex items-baseline gap-2 flex-wrap">
<span className="text-xl sm:text-2xl font-bold text-neutral-900 dark:text-white">
{item.value}
</span>
<span className="flex items-center text-sm font-medium">
{item.trend !== null ? (
<>
<span className={
(item.invertTrend ? -item.trend : item.trend) > 0
? 'text-emerald-600 dark:text-emerald-500'
: (item.invertTrend ? -item.trend : item.trend) < 0
? 'text-red-600 dark:text-red-500'
: 'text-neutral-500'
}>
{(item.invertTrend ? -item.trend : item.trend) > 0 ? (
<ArrowUpRightIcon className="w-3 h-3 mr-0.5 inline" />
) : (item.invertTrend ? -item.trend : item.trend) < 0 ? (
<ArrowDownRightIcon className="w-3 h-3 mr-0.5 inline" />
) : null}
{Math.abs(item.trend)}%
</span>
</>
) : (
<span className="text-neutral-500 dark:text-neutral-400"></span>
)}
</span>
</div>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-0.5">{trendContext}</p>
{hasData && (
<div className="mt-2">
<Sparkline data={chartData} dataKey={item.id} color={item.color} />
</div> </div>
)} <div className="flex items-baseline gap-2">
{metric === item.id && ( <span className="text-xl sm:text-2xl font-bold text-neutral-900 dark:text-white tabular-nums">
<div className="absolute bottom-0 left-0 right-0 h-1" style={{ backgroundColor: item.color }} /> {item.value}
)} </span>
</button> <TrendBadge trend={item.trend} invert={item.invertTrend} />
))} </div>
<p className="text-[11px] text-neutral-400 dark:text-neutral-500 mt-0.5">{trendContext}</p>
{isActive && (
<div className="absolute bottom-0 left-0 right-0 h-[3px] bg-brand-orange" />
)}
</button>
)
})}
</div> </div>
{/* Chart Area */} {/* Chart Area */}
<div className="p-6"> <div className="px-4 sm:px-6 pt-4 pb-2">
{/* Toolbar Row */} {/* Toolbar */}
<div className="mb-6 flex flex-col sm:flex-row sm:items-center justify-between gap-4"> <div className="flex items-center justify-between gap-3 mb-4">
{/* Left side: Legend */} {/* Left: metric label + avg badge */}
<div className="flex items-center"> <div className="flex items-center gap-3">
<span className="text-xs font-medium text-neutral-500 dark:text-neutral-400">
{metricLabel}
</span>
{hasAnyNonZero && avg > 0 && (
<span className="text-[11px] font-medium text-neutral-400 dark:text-neutral-500 bg-neutral-100 dark:bg-neutral-800 px-2 py-0.5 rounded-full">
Avg: {formatAvgLabel(avg, metric)}
</span>
)}
{hasPrev && ( {hasPrev && (
<div className="flex items-center gap-4 text-xs font-medium" style={{ color: colors.textMuted }}> <div className="hidden sm:flex items-center gap-3 text-[11px] font-medium text-neutral-400 dark:text-neutral-500 ml-2">
<span className="flex items-center gap-2"> <span className="flex items-center gap-1.5">
<span <span className="h-1.5 w-1.5 rounded-full bg-brand-orange" />
className="h-2 w-2 rounded-full"
style={{ backgroundColor: activeMetric.color }}
/>
Current Current
</span> </span>
<span className="flex items-center gap-2"> <span className="flex items-center gap-1.5">
<span <span className="h-1.5 w-1.5 rounded-full border border-dashed" style={{ borderColor: colors.axis }} />
className="h-2 w-2 rounded-full border border-dashed"
style={{ borderColor: colors.axis }}
/>
Previous{prevPeriodLabel ? ` (${prevPeriodLabel})` : ''} Previous{prevPeriodLabel ? ` (${prevPeriodLabel})` : ''}
</span> </span>
</div> </div>
)} )}
</div> </div>
{/* Right side: Controls */} {/* Right: controls */}
<div className="flex flex-wrap items-center gap-3 self-end sm:self-auto"> <div className="flex items-center gap-2">
<div className="flex items-center gap-2"> {dateRange.start === dateRange.end ? (
<span className="text-xs text-neutral-500 dark:text-neutral-400">Group by:</span> <Select
{dateRange.start === dateRange.end && ( value={todayInterval}
<Select onChange={(value) => setTodayInterval(value as 'minute' | 'hour')}
value={todayInterval} options={[
onChange={(value) => setTodayInterval(value as 'minute' | 'hour')} { value: 'minute', label: '1 min' },
options={[ { value: 'hour', label: '1 hour' },
{ value: 'minute', label: '1 min' }, ]}
{ value: 'hour', label: '1 hour' }, className="min-w-[90px]"
]} />
className="min-w-[100px]" ) : (
/> <Select
)} value={multiDayInterval}
{dateRange.start !== dateRange.end && ( onChange={(value) => setMultiDayInterval(value as 'hour' | 'day')}
<Select options={[
value={multiDayInterval} { value: 'hour', label: '1 hour' },
onChange={(value) => setMultiDayInterval(value as 'hour' | 'day')} { value: 'day', label: '1 day' },
options={[ ]}
{ value: 'hour', label: '1 hour' }, className="min-w-[90px]"
{ value: 'day', label: '1 day' }, />
]} )}
className="min-w-[100px]"
/>
)}
</div>
{prevData?.length ? ( {prevData?.length ? (
<div className="flex flex-col gap-1"> <Checkbox
<Checkbox checked={showComparison}
checked={showComparison} onCheckedChange={setShowComparison}
onCheckedChange={setShowComparison} label="Compare"
label="Compare" />
/>
{showComparison && prevPeriodLabel && (
<span className="text-xs text-neutral-500 dark:text-neutral-400">
({prevPeriodLabel})
</span>
)}
</div>
) : null} ) : null}
<Button <button
variant="ghost"
onClick={handleExportChart} onClick={handleExportChart}
disabled={!hasData} disabled={!hasData}
className="gap-2 py-1.5 px-3 text-sm text-neutral-600 dark:text-neutral-400" className="p-1.5 text-neutral-400 dark:text-neutral-500 hover:text-neutral-600 dark:hover:text-neutral-300 transition-colors disabled:opacity-30 cursor-pointer"
title="Export chart as PNG"
> >
<DownloadIcon className="w-4 h-4" /> <DownloadIcon className="w-4 h-4" />
Export chart </button>
</Button>
{/* Vertical Separator */}
<div className="h-4 w-px bg-neutral-200 dark:bg-neutral-800" />
</div> </div>
</div> </div>
{!hasData ? ( {!hasData ? (
<div className="flex h-80 flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-neutral-200 dark:border-neutral-700 bg-neutral-50/50 dark:bg-neutral-800/30"> <div className="flex h-72 flex-col items-center justify-center gap-2">
<BarChartIcon className="h-12 w-12 text-neutral-300 dark:text-neutral-600" aria-hidden /> <BarChartIcon className="h-10 w-10 text-neutral-200 dark:text-neutral-700" aria-hidden />
<p className="text-sm font-medium text-neutral-500 dark:text-neutral-400"> <p className="text-sm text-neutral-400 dark:text-neutral-500">No data for this period</p>
No data for this period
</p>
<p className="text-xs text-neutral-400 dark:text-neutral-500">Try a different date range</p>
</div> </div>
) : !hasAnyNonZero ? ( ) : !hasAnyNonZero ? (
<div className="flex h-80 flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-neutral-200 dark:border-neutral-700 bg-neutral-50/50 dark:bg-neutral-800/30"> <div className="flex h-72 flex-col items-center justify-center gap-2">
<BarChartIcon className="h-12 w-12 text-neutral-300 dark:text-neutral-600" aria-hidden /> <BarChartIcon className="h-10 w-10 text-neutral-200 dark:text-neutral-700" aria-hidden />
<p className="text-sm font-medium text-neutral-500 dark:text-neutral-400"> <p className="text-sm text-neutral-400 dark:text-neutral-500">No {metricLabel.toLowerCase()} recorded</p>
No {metricLabel.toLowerCase()} data for this period
</p>
<p className="text-xs text-neutral-400 dark:text-neutral-500">Try selecting another metric or date range</p>
</div> </div>
) : ( ) : (
<div className="h-[360px] w-full flex flex-col"> <div className="h-[320px] w-full">
<div className="text-xs font-medium mb-1 flex-shrink-0" style={{ color: colors.axis }}> <ResponsiveContainer width="100%" height="100%">
{metricLabel} <AreaChart data={chartData} margin={{ top: 8, right: 8, left: 0, bottom: 8 }}>
</div>
<div className="flex-1 min-h-0 w-full">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData} margin={{ top: 4, right: 8, left: 24, bottom: 24 }}>
<defs> <defs>
<linearGradient id={`gradient-${metric}`} x1="0" y1="0" x2="0" y2="1"> <linearGradient id={`gradient-${metric}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={activeMetric.color} stopOpacity={0.35} /> <stop offset="0%" stopColor={COLORS.brand} stopOpacity={0.25} />
<stop offset="50%" stopColor={activeMetric.color} stopOpacity={0.12} /> <stop offset="100%" stopColor={COLORS.brand} stopOpacity={0} />
<stop offset="100%" stopColor={activeMetric.color} stopOpacity={0} />
</linearGradient> </linearGradient>
</defs> </defs>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke={colors.border} /> <CartesianGrid
strokeDasharray="3 3"
vertical={false}
stroke={colors.grid}
/>
<XAxis <XAxis
dataKey="date" dataKey="date"
stroke={colors.axis} stroke={colors.axis}
fontSize={12} fontSize={11}
tickLine={false} tickLine={false}
axisLine={false} axisLine={false}
minTickGap={28} minTickGap={40}
ticks={midnightTicks ?? dayTicks} ticks={midnightTicks ?? dayTicks}
dy={8}
/> />
<YAxis <YAxis
stroke={colors.axis} stroke={colors.axis}
fontSize={12} fontSize={11}
tickLine={false} tickLine={false}
axisLine={false} axisLine={false}
domain={[0, 'auto']} domain={[0, 'auto']}
width={24} width={40}
allowDecimals={!isCountMetric}
tickFormatter={(val) => { tickFormatter={(val) => {
if (metric === 'bounce_rate') return `${val}%` if (metric === 'bounce_rate') return `${val}%`
if (metric === 'avg_duration') return formatAxisDuration(val) if (metric === 'avg_duration') return formatAxisDuration(val)
@@ -584,12 +498,9 @@ export default function Chart({
content={(p: TooltipProps<number, string>) => ( content={(p: TooltipProps<number, string>) => (
<ChartTooltip <ChartTooltip
active={p.active} active={p.active}
payload={p.payload as Array<{ payload={p.payload as Array<{ payload: Record<string, number>; value: number; dataKey?: string }>}
payload: { prevPageviews?: number; prevVisitors?: number }
value: number
}>}
label={p.label as string} label={p.label as string}
metric={chartMetric} metric={metric}
metricLabel={metricLabel} metricLabel={metricLabel}
formatNumberFn={formatNumber} formatNumberFn={formatNumber}
showComparison={hasPrev} showComparison={hasPrev}
@@ -597,7 +508,7 @@ export default function Chart({
colors={colors} colors={colors}
/> />
)} )}
cursor={{ stroke: activeMetric.color, strokeDasharray: '4 4', strokeWidth: 1 }} cursor={{ stroke: colors.axis, strokeOpacity: 0.3, strokeWidth: 1 }}
/> />
{avg > 0 && ( {avg > 0 && (
@@ -605,34 +516,23 @@ export default function Chart({
y={avg} y={avg}
stroke={colors.axis} stroke={colors.axis}
strokeDasharray="4 4" strokeDasharray="4 4"
strokeOpacity={0.7} strokeOpacity={0.4}
label={{
value: `Avg: ${metric === 'bounce_rate' ? `${Math.round(avg)}%` : metric === 'avg_duration' ? formatAxisDuration(avg) : formatAxisValue(avg)}`,
position: 'insideTopRight',
fill: colors.axis,
fontSize: 11,
}}
/> />
)} )}
{hasPrev && ( {hasPrev && (
<Area <Area
type="monotone" type="monotone"
dataKey={ dataKey={metric === 'visitors' ? 'prevVisitors' : metric === 'pageviews' ? 'prevPageviews' : metric === 'bounce_rate' ? 'prevBounceRate' : 'prevAvgDuration'}
chartMetric === 'visitors' ? 'prevVisitors' :
chartMetric === 'pageviews' ? 'prevPageviews' :
chartMetric === 'bounce_rate' ? 'prevBounceRate' :
'prevAvgDuration'
}
stroke={colors.axis} stroke={colors.axis}
strokeWidth={2} strokeWidth={1.5}
strokeDasharray="5 5" strokeDasharray="4 4"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
fill="none" fill="none"
dot={false} dot={false}
isAnimationActive isAnimationActive
animationDuration={500} animationDuration={400}
animationEasing="ease-out" animationEasing="ease-out"
/> />
)} )}
@@ -640,30 +540,42 @@ export default function Chart({
<Area <Area
type="monotone" type="monotone"
baseValue={0} baseValue={0}
dataKey={chartMetric} dataKey={metric}
stroke={activeMetric.color} stroke={COLORS.brand}
strokeWidth={2.5} strokeWidth={2}
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
fillOpacity={1} fillOpacity={1}
fill={`url(#gradient-${metric})`} fill={`url(#gradient-${metric})`}
dot={false} dot={false}
activeDot={{ activeDot={{
r: 5, r: 4,
strokeWidth: 2, strokeWidth: 2,
fill: resolvedTheme === 'dark' ? 'var(--color-neutral-800)' : '#ffffff', fill: resolvedTheme === 'dark' ? 'var(--color-neutral-900)' : '#ffffff',
stroke: activeMetric.color, stroke: COLORS.brand,
}} }}
isAnimationActive isAnimationActive
animationDuration={500} animationDuration={400}
animationEasing="ease-out" animationEasing="ease-out"
/> />
</AreaChart> </AreaChart>
</ResponsiveContainer> </ResponsiveContainer>
</div>
</div> </div>
)} )}
</div> </div>
{/* Live indicator */}
{lastUpdatedAt != null && (
<div className="px-4 sm:px-6 pb-3 flex justify-end">
<div className="flex items-center gap-1.5 text-[11px] text-neutral-400 dark:text-neutral-500">
<span className="relative flex h-1.5 w-1.5">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-500 opacity-75" />
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-green-500" />
</span>
Live · {formatUpdatedAgo(lastUpdatedAt)}
</div>
</div>
)}
</div> </div>
) )
} }