feat: add compact duration formatting for Y-axis ticks and improve trend display in Chart component
This commit is contained in:
@@ -11,7 +11,6 @@ import {
|
|||||||
Tooltip,
|
Tooltip,
|
||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
ReferenceLine,
|
ReferenceLine,
|
||||||
Label,
|
|
||||||
} from 'recharts'
|
} from 'recharts'
|
||||||
import type { TooltipProps } from 'recharts'
|
import type { TooltipProps } from 'recharts'
|
||||||
import { formatNumber, formatDuration } from '@/lib/utils/format'
|
import { formatNumber, formatDuration } from '@/lib/utils/format'
|
||||||
@@ -169,6 +168,15 @@ 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 {
|
||||||
|
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`
|
||||||
|
return `${s}s`
|
||||||
|
}
|
||||||
|
|
||||||
// * Returns human-readable label for the previous comparison period (e.g. "Feb 10" or "Jan 5 – Feb 4")
|
// * Returns human-readable label for the previous comparison period (e.g. "Feb 10" or "Jan 5 – Feb 4")
|
||||||
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)
|
||||||
@@ -368,7 +376,7 @@ export default function Chart({
|
|||||||
const chartMetric = metric
|
const chartMetric = metric
|
||||||
const metricLabel = metrics.find(m => m.id === metric)?.label || 'visitors'
|
const metricLabel = metrics.find(m => m.id === metric)?.label || 'visitors'
|
||||||
const prevPeriodLabel = prevData?.length ? getPrevDateRangeLabel(dateRange) : ''
|
const prevPeriodLabel = prevData?.length ? getPrevDateRangeLabel(dateRange) : ''
|
||||||
const trendContext = prevStats ? 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[chartMetric] as number), 0) / chartData.length
|
||||||
@@ -425,26 +433,30 @@ export default function Chart({
|
|||||||
<span className="text-xl sm:text-2xl font-bold text-neutral-900 dark:text-white">
|
<span className="text-xl sm:text-2xl font-bold text-neutral-900 dark:text-white">
|
||||||
{item.value}
|
{item.value}
|
||||||
</span>
|
</span>
|
||||||
{item.trend !== null && (
|
<span className="flex items-center text-sm font-medium">
|
||||||
<span className={`flex items-center text-sm font-medium ${
|
{item.trend !== null ? (
|
||||||
(item.invertTrend ? -item.trend : item.trend) > 0
|
<>
|
||||||
? 'text-emerald-600 dark:text-emerald-500'
|
<span className={
|
||||||
: (item.invertTrend ? -item.trend : item.trend) < 0
|
(item.invertTrend ? -item.trend : item.trend) > 0
|
||||||
? 'text-red-600 dark:text-red-500'
|
? 'text-emerald-600 dark:text-emerald-500'
|
||||||
: 'text-neutral-500'
|
: (item.invertTrend ? -item.trend : item.trend) < 0
|
||||||
}`}>
|
? 'text-red-600 dark:text-red-500'
|
||||||
{(item.invertTrend ? -item.trend : item.trend) > 0 ? (
|
: 'text-neutral-500'
|
||||||
<ArrowUpRightIcon className="w-3 h-3 mr-0.5" />
|
}>
|
||||||
) : (item.invertTrend ? -item.trend : item.trend) < 0 ? (
|
{(item.invertTrend ? -item.trend : item.trend) > 0 ? (
|
||||||
<ArrowDownRightIcon className="w-3 h-3 mr-0.5" />
|
<ArrowUpRightIcon className="w-3 h-3 mr-0.5 inline" />
|
||||||
) : null}
|
) : (item.invertTrend ? -item.trend : item.trend) < 0 ? (
|
||||||
{Math.abs(item.trend)}%
|
<ArrowDownRightIcon className="w-3 h-3 mr-0.5 inline" />
|
||||||
</span>
|
) : null}
|
||||||
)}
|
{Math.abs(item.trend)}%
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-neutral-500 dark:text-neutral-400">—</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{trendContext && item.trend !== null && (
|
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-0.5">{trendContext}</p>
|
||||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-0.5">{trendContext}</p>
|
|
||||||
)}
|
|
||||||
{hasData && (
|
{hasData && (
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<Sparkline data={chartData} dataKey={item.id} color={item.color} />
|
<Sparkline data={chartData} dataKey={item.id} color={item.color} />
|
||||||
@@ -516,7 +528,7 @@ export default function Chart({
|
|||||||
<Checkbox
|
<Checkbox
|
||||||
checked={showComparison}
|
checked={showComparison}
|
||||||
onCheckedChange={setShowComparison}
|
onCheckedChange={setShowComparison}
|
||||||
label="Compare with previous period"
|
label="Compare"
|
||||||
/>
|
/>
|
||||||
{showComparison && prevPeriodLabel && (
|
{showComparison && prevPeriodLabel && (
|
||||||
<span className="text-xs text-neutral-500 dark:text-neutral-400">
|
<span className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||||
@@ -558,9 +570,26 @@ export default function Chart({
|
|||||||
<p className="text-xs text-neutral-400 dark:text-neutral-500">Try selecting another metric or date range</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">
|
<div className="h-[360px] w-full flex flex-row items-stretch gap-2">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
{/* * Vertical Y-axis label (text reads bottom-to-top) */}
|
||||||
<AreaChart data={chartData} margin={{ top: 10, right: 8, left: -16, bottom: 0 }}>
|
<div
|
||||||
|
className="flex items-center justify-center flex-shrink-0"
|
||||||
|
style={{ width: 28 }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="text-xs font-medium whitespace-nowrap"
|
||||||
|
style={{
|
||||||
|
color: colors.axis,
|
||||||
|
transform: 'rotate(-90deg)',
|
||||||
|
transformOrigin: 'center center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{metricLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-h-0 min-w-0">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<AreaChart data={chartData} margin={{ top: 4, right: 8, left: 50, bottom: 0 }}>
|
||||||
<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={activeMetric.color} stopOpacity={0.35} />
|
||||||
@@ -584,19 +613,13 @@ export default function Chart({
|
|||||||
tickLine={false}
|
tickLine={false}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
domain={[0, 'auto']}
|
domain={[0, 'auto']}
|
||||||
|
width={48}
|
||||||
tickFormatter={(val) => {
|
tickFormatter={(val) => {
|
||||||
if (metric === 'bounce_rate') return `${val}%`
|
if (metric === 'bounce_rate') return `${val}%`
|
||||||
if (metric === 'avg_duration') return formatDuration(val)
|
if (metric === 'avg_duration') return formatAxisDuration(val)
|
||||||
return formatAxisValue(val)
|
return formatAxisValue(val)
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
<Label
|
|
||||||
value={metricLabel}
|
|
||||||
position="insideTopLeft"
|
|
||||||
offset={8}
|
|
||||||
style={{ fill: colors.axis, fontSize: 11, fontWeight: 500 }}
|
|
||||||
/>
|
|
||||||
</YAxis>
|
|
||||||
<Tooltip
|
<Tooltip
|
||||||
content={(p: TooltipProps<number, string>) => (
|
content={(p: TooltipProps<number, string>) => (
|
||||||
<ChartTooltip
|
<ChartTooltip
|
||||||
@@ -675,8 +698,9 @@ export default function Chart({
|
|||||||
animationDuration={500}
|
animationDuration={500}
|
||||||
animationEasing="ease-out"
|
animationEasing="ease-out"
|
||||||
/>
|
/>
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user