feat(dashboard): extend Chart component to include bounce rate and average duration metrics for enhanced analytics
This commit is contained in:
@@ -46,6 +46,8 @@ interface DailyStat {
|
|||||||
date: string
|
date: string
|
||||||
pageviews: number
|
pageviews: number
|
||||||
visitors: number
|
visitors: number
|
||||||
|
bounce_rate: number
|
||||||
|
avg_duration: number
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Stats {
|
interface Stats {
|
||||||
@@ -77,9 +79,9 @@ function ChartTooltip({
|
|||||||
colors,
|
colors,
|
||||||
}: {
|
}: {
|
||||||
active?: boolean
|
active?: boolean
|
||||||
payload?: Array<{ payload: { prevPageviews?: number; prevVisitors?: number }; value: number }>
|
payload?: Array<{ payload: { prevPageviews?: number; prevVisitors?: number; prevBounceRate?: number; prevAvgDuration?: number }; value: number }>
|
||||||
label?: string
|
label?: string
|
||||||
metric: 'visitors' | 'pageviews'
|
metric: MetricType
|
||||||
metricLabel: string
|
metricLabel: string
|
||||||
formatNumberFn: (n: number) => string
|
formatNumberFn: (n: number) => string
|
||||||
showComparison: boolean
|
showComparison: boolean
|
||||||
@@ -88,17 +90,31 @@ function ChartTooltip({
|
|||||||
if (!active || !payload?.length || !label) return null
|
if (!active || !payload?.length || !label) return null
|
||||||
// * Recharts sends one payload entry per Area; order can be [prevSeries, currentSeries].
|
// * 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.
|
// * 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; visitors?: number; pageviews?: number } }
|
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 items = payload as PayloadItem[]
|
||||||
const current = items.find((p) => p.dataKey === metric) ?? items[items.length - 1]
|
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)
|
const value = Number(current?.value ?? (current?.payload as Record<string, number>)?.[metric] ?? 0)
|
||||||
const prev = metric === 'visitors' ? current?.payload?.prevVisitors : current?.payload?.prevPageviews
|
|
||||||
|
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 hasPrev = showComparison && prev != null
|
||||||
const delta =
|
const delta =
|
||||||
hasPrev && (prev as number) > 0
|
hasPrev && (prev as number) > 0
|
||||||
? Math.round(((value - (prev as number)) / (prev as number)) * 100)
|
? Math.round(((value - (prev as number)) / (prev as number)) * 100)
|
||||||
: null
|
: null
|
||||||
|
|
||||||
|
const formatValue = (v: number) => {
|
||||||
|
if (metric === 'bounce_rate') return `${Math.round(v)}%`
|
||||||
|
if (metric === 'avg_duration') return formatDuration(v)
|
||||||
|
return formatNumberFn(v)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="rounded-lg border px-4 py-3 shadow-lg"
|
className="rounded-lg border px-4 py-3 shadow-lg"
|
||||||
@@ -112,7 +128,7 @@ function ChartTooltip({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-baseline gap-2">
|
<div className="flex items-baseline gap-2">
|
||||||
<span className="text-base font-bold" style={{ color: colors.text }}>
|
<span className="text-base font-bold" style={{ color: colors.text }}>
|
||||||
{formatNumberFn(value)}
|
{formatValue(value)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs" style={{ color: colors.textMuted }}>
|
<span className="text-xs" style={{ color: colors.textMuted }}>
|
||||||
{metricLabel}
|
{metricLabel}
|
||||||
@@ -120,12 +136,12 @@ function ChartTooltip({
|
|||||||
</div>
|
</div>
|
||||||
{hasPrev && (
|
{hasPrev && (
|
||||||
<div className="mt-1.5 flex items-center gap-1.5 text-xs" style={{ color: colors.textMuted }}>
|
<div className="mt-1.5 flex items-center gap-1.5 text-xs" style={{ color: colors.textMuted }}>
|
||||||
<span>vs {formatNumberFn(prev as number)} prev</span>
|
<span>vs {formatValue(prev as number)} prev</span>
|
||||||
{delta !== null && (
|
{delta !== null && (
|
||||||
<span
|
<span
|
||||||
className="font-medium"
|
className="font-medium"
|
||||||
style={{
|
style={{
|
||||||
color: delta > 0 ? COLORS.success : delta < 0 ? COLORS.danger : colors.textMuted,
|
color: delta > 0 ? (metric === 'bounce_rate' ? COLORS.danger : COLORS.success) : delta < 0 ? (metric === 'bounce_rate' ? COLORS.success : COLORS.danger) : colors.textMuted,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{delta > 0 ? '+' : ''}{delta}%
|
{delta > 0 ? '+' : ''}{delta}%
|
||||||
@@ -180,8 +196,12 @@ export default function Chart({ data, prevData, stats, prevStats, interval }: Ch
|
|||||||
originalDate: item.date,
|
originalDate: item.date,
|
||||||
pageviews: item.pageviews,
|
pageviews: item.pageviews,
|
||||||
visitors: item.visitors,
|
visitors: item.visitors,
|
||||||
|
bounce_rate: item.bounce_rate,
|
||||||
|
avg_duration: item.avg_duration,
|
||||||
prevPageviews: prevItem?.pageviews,
|
prevPageviews: prevItem?.pageviews,
|
||||||
prevVisitors: prevItem?.visitors,
|
prevVisitors: prevItem?.visitors,
|
||||||
|
prevBounceRate: prevItem?.bounce_rate,
|
||||||
|
prevAvgDuration: prevItem?.avg_duration,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -242,8 +262,8 @@ export default function Chart({ data, prevData, stats, prevStats, interval }: Ch
|
|||||||
] as const
|
] 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 === 'visitors' || metric === 'pageviews' ? metric : 'visitors'
|
const chartMetric = metric
|
||||||
const metricLabel = chartMetric === 'pageviews' ? 'pageviews' : 'visitors'
|
const metricLabel = metrics.find(m => m.id === metric)?.label || 'visitors'
|
||||||
|
|
||||||
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[chartMetric] as number), 0) / chartData.length
|
||||||
@@ -275,16 +295,12 @@ export default function Chart({ data, prevData, stats, prevStats, interval }: Ch
|
|||||||
{metrics.map((item) => (
|
{metrics.map((item) => (
|
||||||
<button
|
<button
|
||||||
key={item.id}
|
key={item.id}
|
||||||
onClick={() => {
|
onClick={() => setMetric(item.id as MetricType)}
|
||||||
if (item.id === 'visitors' || item.id === 'pageviews') {
|
|
||||||
setMetric(item.id as MetricType)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className={`
|
className={`
|
||||||
p-6 text-left transition-colors relative group
|
p-6 text-left transition-colors relative group
|
||||||
hover:bg-neutral-50 dark:hover:bg-neutral-800/50
|
hover:bg-neutral-50 dark:hover:bg-neutral-800/50
|
||||||
${metric === item.id ? 'bg-neutral-50 dark:bg-neutral-800/50' : ''}
|
${metric === item.id ? 'bg-neutral-50 dark:bg-neutral-800/50' : ''}
|
||||||
${(item.id !== 'visitors' && item.id !== 'pageviews') ? 'cursor-default' : 'cursor-pointer'}
|
cursor-pointer
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
<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'}`}>
|
<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'}`}>
|
||||||
@@ -404,7 +420,11 @@ export default function Chart({ data, prevData, stats, prevStats, interval }: Ch
|
|||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
domain={[0, 'auto']}
|
domain={[0, 'auto']}
|
||||||
tickFormatter={formatAxisValue}
|
tickFormatter={(val) => {
|
||||||
|
if (metric === 'bounce_rate') return `${val}%`
|
||||||
|
if (metric === 'avg_duration') return formatDuration(val)
|
||||||
|
return formatAxisValue(val)
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
content={(p: TooltipProps<number, string>) => (
|
content={(p: TooltipProps<number, string>) => (
|
||||||
@@ -437,7 +457,12 @@ export default function Chart({ data, prevData, stats, prevStats, interval }: Ch
|
|||||||
{hasPrev && (
|
{hasPrev && (
|
||||||
<Area
|
<Area
|
||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey={chartMetric === 'visitors' ? 'prevVisitors' : 'prevPageviews'}
|
dataKey={
|
||||||
|
chartMetric === 'visitors' ? 'prevVisitors' :
|
||||||
|
chartMetric === 'pageviews' ? 'prevPageviews' :
|
||||||
|
chartMetric === 'bounce_rate' ? 'prevBounceRate' :
|
||||||
|
'prevAvgDuration'
|
||||||
|
}
|
||||||
stroke={colors.axis}
|
stroke={colors.axis}
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
strokeDasharray="5 5"
|
strokeDasharray="5 5"
|
||||||
|
|||||||
@@ -74,6 +74,8 @@ export interface DailyStat {
|
|||||||
date: string
|
date: string
|
||||||
pageviews: number
|
pageviews: number
|
||||||
visitors: number
|
visitors: number
|
||||||
|
bounce_rate: number
|
||||||
|
avg_duration: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RealtimeStats {
|
export interface RealtimeStats {
|
||||||
|
|||||||
Reference in New Issue
Block a user