Skip to content

Commit 676c42c

Browse files
committed
feat: drag to select date ranges on chart
1 parent 715b9bd commit 676c42c

File tree

2 files changed

+132
-88
lines changed

2 files changed

+132
-88
lines changed

apps/dashboard/app/(main)/websites/[id]/_components/tabs/overview-tab.tsx

Lines changed: 35 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232
} from '@/components/table/rows';
3333
import { useBatchDynamicQuery } from '@/hooks/use-dynamic-query';
3434
import { getUserTimezone } from '@/lib/timezone';
35+
import { useDateFilters } from '@/hooks/use-date-filters';
3536
import {
3637
metricVisibilityAtom,
3738
toggleMetricAtom,
@@ -52,6 +53,7 @@ const CustomEventsSection = dynamic(() =>
5253

5354
interface ChartDataPoint {
5455
date: string;
56+
rawDate?: string;
5557
pageviews?: number;
5658
visitors?: number;
5759
sessions?: number;
@@ -142,11 +144,15 @@ export function WebsiteOverviewTab({
142144
[]
143145
);
144146

147+
const { setDateRangeAction } = useDateFilters();
148+
145149
const previousPeriodRange = useMemo(
146150
() => calculatePreviousPeriod(dateRange),
147151
[dateRange, calculatePreviousPeriod]
148152
);
149153

154+
const [visibleMetrics] = useAtom(metricVisibilityAtom);
155+
150156
const queries = [
151157
{
152158
id: 'overview-summary',
@@ -241,26 +247,6 @@ export function WebsiteOverviewTab({
241247
getDataForQuery('overview-custom-events', 'outbound_domains') || [],
242248
};
243249

244-
const [visibleMetrics] = useAtom(metricVisibilityAtom);
245-
const [, toggleMetricAction] = useAtom(toggleMetricAtom);
246-
247-
const toggleMetric = useCallback(
248-
(metric: string) => {
249-
if (metric in visibleMetrics) {
250-
toggleMetricAction(metric as keyof typeof visibleMetrics);
251-
}
252-
},
253-
[visibleMetrics, toggleMetricAction]
254-
);
255-
256-
const hiddenMetrics = useMemo(() => {
257-
const result: Record<string, boolean> = {};
258-
for (const key of Object.keys(visibleMetrics)) {
259-
result[key] = !visibleMetrics[key as keyof typeof visibleMetrics];
260-
}
261-
return result;
262-
}, [visibleMetrics]);
263-
264250
const createPercentageCell = () => (info: CellInfo) => {
265251
const percentage = info.getValue() as number;
266252
return <PercentageBadge percentage={percentage} />;
@@ -355,32 +341,22 @@ export function WebsiteOverviewTab({
355341
);
356342

357343
const chartData = useMemo(() => {
358-
if (!analytics.events_by_date?.length) {
359-
return [];
360-
}
344+
if (!analytics.events_by_date?.length) return [];
345+
361346
const filteredEvents = filterFutureEvents(analytics.events_by_date);
362-
return filteredEvents.map((event: MetricPoint): ChartDataPoint => {
363-
const filtered: ChartDataPoint = {
364-
date: formatDateByGranularity(event.date, dateRange.granularity),
365-
};
366-
if (visibleMetrics.pageviews) {
367-
filtered.pageviews = event.pageviews as number;
368-
}
369-
if (visibleMetrics.visitors) {
370-
filtered.visitors =
371-
(event.visitors as number) || (event.unique_visitors as number) || 0;
372-
}
373-
if (visibleMetrics.sessions) {
374-
filtered.sessions = event.sessions as number;
375-
}
376-
if (visibleMetrics.bounce_rate) {
377-
filtered.bounce_rate = event.bounce_rate as number;
378-
}
379-
if (visibleMetrics.avg_session_duration) {
380-
filtered.avg_session_duration = event.avg_session_duration as number;
381-
}
382-
return filtered;
383-
});
347+
return filteredEvents.map((event: MetricPoint): ChartDataPoint => ({
348+
date: formatDateByGranularity(event.date, dateRange.granularity),
349+
rawDate: event.date,
350+
...(visibleMetrics.pageviews && { pageviews: event.pageviews as number }),
351+
...(visibleMetrics.visitors && {
352+
visitors: (event.visitors as number) || (event.unique_visitors as number) || 0
353+
}),
354+
...(visibleMetrics.sessions && { sessions: event.sessions as number }),
355+
...(visibleMetrics.bounce_rate && { bounce_rate: event.bounce_rate as number }),
356+
...(visibleMetrics.avg_session_duration && {
357+
avg_session_duration: event.avg_session_duration as number
358+
}),
359+
}));
384360
}, [
385361
analytics.events_by_date,
386362
dateRange.granularity,
@@ -389,37 +365,32 @@ export function WebsiteOverviewTab({
389365
]);
390366

391367
const miniChartData = useMemo(() => {
392-
if (!analytics.events_by_date?.length) {
393-
return {};
394-
}
368+
if (!analytics.events_by_date?.length) return {};
369+
395370
const filteredEvents = filterFutureEvents(analytics.events_by_date);
396371
const createChartSeries = (
397372
field: keyof MetricPoint,
398373
transform?: (value: number) => number
399374
) =>
400375
filteredEvents.map((event: MetricPoint) => ({
401-
date:
402-
dateRange.granularity === 'hourly'
403-
? event.date
404-
: event.date.slice(0, 10),
405-
value: transform
406-
? transform(event[field] as number)
407-
: (event[field] as number) || 0,
376+
date: dateRange.granularity === 'hourly' ? event.date : event.date.slice(0, 10),
377+
value: transform ? transform(event[field] as number) : (event[field] as number) || 0,
408378
}));
379+
380+
const formatSessionDuration = (value: number) => {
381+
if (value < 60) return Math.round(value);
382+
const minutes = Math.floor(value / 60);
383+
const seconds = Math.round(value % 60);
384+
return minutes * 60 + seconds;
385+
};
386+
409387
return {
410388
visitors: createChartSeries('visitors'),
411389
sessions: createChartSeries('sessions'),
412390
pageviews: createChartSeries('pageviews'),
413391
pagesPerSession: createChartSeries('pages_per_session'),
414392
bounceRate: createChartSeries('bounce_rate'),
415-
sessionDuration: createChartSeries('avg_session_duration', (value) => {
416-
if (value < 60) {
417-
return Math.round(value);
418-
}
419-
const minutes = Math.floor(value / 60);
420-
const seconds = Math.round(value % 60);
421-
return minutes * 60 + seconds;
422-
}),
393+
sessionDuration: createChartSeries('avg_session_duration', formatSessionDuration),
423394
};
424395
}, [analytics.events_by_date, dateRange.granularity, filterFutureEvents]);
425396

@@ -968,9 +939,8 @@ export function WebsiteOverviewTab({
968939
className="rounded border-0"
969940
data={chartData}
970941
height={350}
971-
hiddenMetrics={hiddenMetrics}
972942
isLoading={isLoading}
973-
onToggleMetric={toggleMetric}
943+
onRangeSelect={setDateRangeAction}
974944
/>
975945
</div>
976946
</div>

apps/dashboard/components/charts/metrics-chart.tsx

Lines changed: 97 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import { ChartLineIcon } from '@phosphor-icons/react';
2+
import { useAtom } from 'jotai';
3+
import { useMemo, useState } from 'react';
24
import {
35
Area,
46
CartesianGrid,
57
ComposedChart,
68
Legend,
9+
ReferenceArea,
710
ResponsiveContainer,
811
Tooltip,
912
XAxis,
@@ -23,6 +26,10 @@ import {
2326
type MetricConfig,
2427
} from './metrics-constants';
2528
import { SkeletonChart } from './skeleton-chart';
29+
import {
30+
metricVisibilityAtom,
31+
toggleMetricAtom,
32+
} from '@/stores/jotai/chartAtoms';
2633

2734
const CustomTooltip = ({ active, payload, label }: any) => {
2835
if (!(active && payload?.length)) {
@@ -76,17 +83,21 @@ const CustomTooltip = ({ active, payload, label }: any) => {
7683
);
7784
};
7885

86+
interface DateRangeState {
87+
startDate: Date;
88+
endDate: Date;
89+
}
90+
7991
interface MetricsChartProps {
8092
data: ChartDataRow[] | undefined;
8193
isLoading: boolean;
8294
height?: number;
8395
title?: string;
8496
description?: string;
8597
className?: string;
86-
hiddenMetrics?: Record<string, boolean>;
87-
onToggleMetric?: (metricKey: string) => void;
8898
metricsFilter?: (metric: MetricConfig) => boolean;
8999
showLegend?: boolean;
100+
onRangeSelect?: (dateRange: DateRangeState) => void;
90101
}
91102

92103
export function MetricsChart({
@@ -96,41 +107,87 @@ export function MetricsChart({
96107
title,
97108
description,
98109
className,
99-
hiddenMetrics = {},
100-
onToggleMetric,
101110
metricsFilter,
102111
showLegend = true,
112+
onRangeSelect,
103113
}: MetricsChartProps) {
104114
const rawData = data || [];
115+
const [refAreaLeft, setRefAreaLeft] = useState<string | null>(null);
116+
const [refAreaRight, setRefAreaRight] = useState<string | null>(null);
105117

118+
const [visibleMetrics] = useAtom(metricVisibilityAtom);
119+
const [, toggleMetric] = useAtom(toggleMetricAtom);
120+
121+
const hiddenMetrics = useMemo(
122+
() => Object.fromEntries(
123+
Object.entries(visibleMetrics).map(([key, visible]) => [key, !visible])
124+
),
125+
[visibleMetrics]
126+
);
127+
128+
const DEFAULT_METRICS = ['pageviews', 'visitors', 'sessions', 'bounce_rate', 'avg_session_duration'];
129+
106130
const metrics = metricsFilter
107131
? METRICS.filter(metricsFilter)
108-
: METRICS.filter((metric) =>
109-
[
110-
'pageviews',
111-
'visitors',
112-
'sessions',
113-
'bounce_rate',
114-
'avg_session_duration',
115-
].includes(metric.key)
116-
);
132+
: METRICS.filter((metric) => DEFAULT_METRICS.includes(metric.key));
117133

118134
const chartData = rawData.map((item, index) => {
119-
const isFutureOrCurrent = index === rawData.length - 1;
120-
const connectsToFuture = index === rawData.length - 2;
135+
const isLastPoint = index === rawData.length - 1;
136+
const isSecondToLast = index === rawData.length - 2;
121137

122138
const result = { ...item };
123139
for (const metric of metrics) {
124-
result[`${metric.key}_historical`] = isFutureOrCurrent
125-
? null
126-
: item[metric.key];
127-
if (isFutureOrCurrent || connectsToFuture) {
140+
result[`${metric.key}_historical`] = isLastPoint ? null : item[metric.key];
141+
if (isLastPoint || isSecondToLast) {
128142
result[`${metric.key}_future`] = item[metric.key];
129143
}
130144
}
131145
return result;
132146
});
133147

148+
const handleMouseDown = (e: any) => {
149+
if (!e?.activeLabel) return;
150+
setRefAreaLeft(e.activeLabel);
151+
setRefAreaRight(null);
152+
};
153+
154+
const handleMouseMove = (e: any) => {
155+
if (!(refAreaLeft && e?.activeLabel)) return;
156+
setRefAreaRight(e.activeLabel);
157+
};
158+
159+
const handleMouseUp = () => {
160+
if (!(refAreaLeft && refAreaRight && onRangeSelect)) {
161+
setRefAreaLeft(null);
162+
setRefAreaRight(null);
163+
return;
164+
}
165+
166+
const leftIndex = chartData.findIndex((d) => d.date === refAreaLeft);
167+
const rightIndex = chartData.findIndex((d) => d.date === refAreaRight);
168+
169+
if (leftIndex === -1 || rightIndex === -1) {
170+
setRefAreaLeft(null);
171+
setRefAreaRight(null);
172+
return;
173+
}
174+
175+
const [startIndex, endIndex] = leftIndex < rightIndex
176+
? [leftIndex, rightIndex]
177+
: [rightIndex, leftIndex];
178+
179+
const startDateStr = (chartData[startIndex] as any).rawDate || chartData[startIndex].date;
180+
const endDateStr = (chartData[endIndex] as any).rawDate || chartData[endIndex].date;
181+
182+
onRangeSelect({
183+
startDate: new Date(startDateStr),
184+
endDate: new Date(endDateStr)
185+
});
186+
187+
setRefAreaLeft(null);
188+
setRefAreaRight(null);
189+
};
190+
134191
if (isLoading) {
135192
return <SkeletonChart className="w-full" height={height} title={title} />;
136193
}
@@ -178,8 +235,13 @@ export function MetricsChart({
178235
<Card className={cn('w-full overflow-hidden rounded-none p-0', className)}>
179236
<CardContent className="p-0">
180237
<div
181-
className="relative"
182-
style={{ width: '100%', height: height + 20 }}
238+
className="relative select-none"
239+
style={{
240+
width: '100%',
241+
height: height + 20,
242+
userSelect: refAreaLeft ? 'none' : 'auto',
243+
WebkitUserSelect: refAreaLeft ? 'none' : 'auto',
244+
}}
183245
>
184246
<ResponsiveContainer height="100%" width="100%">
185247
<ComposedChart
@@ -190,6 +252,9 @@ export function MetricsChart({
190252
left: 20,
191253
bottom: chartData.length > 5 ? 60 : 20,
192254
}}
255+
onMouseDown={handleMouseDown}
256+
onMouseMove={handleMouseMove}
257+
onMouseUp={handleMouseUp}
193258
>
194259
<defs>
195260
{metrics.map((metric) => (
@@ -236,6 +301,15 @@ export function MetricsChart({
236301
content={<CustomTooltip />}
237302
cursor={{ stroke: 'var(--primary)', strokeDasharray: '4 4' }}
238303
/>
304+
{refAreaLeft && refAreaRight && (
305+
<ReferenceArea
306+
x1={refAreaLeft}
307+
x2={refAreaRight}
308+
strokeOpacity={0.3}
309+
fill="var(--primary)"
310+
fillOpacity={0.15}
311+
/>
312+
)}
239313
{showLegend && (
240314
<Legend
241315
align="center"
@@ -258,8 +332,8 @@ export function MetricsChart({
258332
const metric = metrics.find(
259333
(m) => m.label === payload.value
260334
);
261-
if (metric && onToggleMetric) {
262-
onToggleMetric(metric.key);
335+
if (metric) {
336+
toggleMetric(metric.key as keyof typeof visibleMetrics);
263337
}
264338
}}
265339
verticalAlign="bottom"

0 commit comments

Comments
 (0)