Skip to content

Commit 7f06631

Browse files
committed
fix: disable hourly data for 7+ days for performance reasons
1 parent 24b7f7b commit 7f06631

File tree

4 files changed

+153
-106
lines changed

4 files changed

+153
-106
lines changed

apps/api/src/routes/query.ts

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -317,10 +317,35 @@ async function executeDynamicQuery(
317317
? (domainCache?.[websiteId] ?? (await getWebsiteDomain(websiteId)))
318318
: null;
319319

320-
const getTimeUnit = (granularity?: string): 'hour' | 'day' => {
321-
if (['hourly', 'hour'].includes(granularity || '')) {
320+
const MAX_HOURLY_DAYS = 7;
321+
const MS_PER_DAY = 1000 * 60 * 60 * 24;
322+
323+
const validateHourlyDateRange = (start: string, end: string) => {
324+
const rangeDays = Math.ceil(
325+
(new Date(end).getTime() - new Date(start).getTime()) / MS_PER_DAY
326+
);
327+
328+
if (rangeDays > MAX_HOURLY_DAYS) {
329+
throw new Error(
330+
`Hourly granularity only supports ranges up to ${MAX_HOURLY_DAYS} days. Use daily granularity for longer periods.`
331+
);
332+
}
333+
};
334+
335+
const getTimeUnit = (
336+
granularity?: string,
337+
startDate?: string,
338+
endDate?: string
339+
): 'hour' | 'day' => {
340+
const isHourly = ['hourly', 'hour'].includes(granularity || '');
341+
342+
if (isHourly) {
343+
if (startDate && endDate) {
344+
validateHourlyDateRange(startDate, endDate);
345+
}
322346
return 'hour';
323347
}
348+
324349
return 'day';
325350
};
326351

@@ -405,7 +430,11 @@ async function executeDynamicQuery(
405430
type: parameterName,
406431
from: validation.start,
407432
to: validation.end,
408-
timeUnit: getTimeUnit(paramGranularity),
433+
timeUnit: getTimeUnit(
434+
paramGranularity,
435+
validation.start,
436+
validation.end
437+
),
409438
filters: dynamicRequest.filters || [],
410439
limit: dynamicRequest.limit || 100,
411440
offset: dynamicRequest.page

apps/dashboard/app/(main)/websites/[id]/_components/analytics-toolbar.tsx

Lines changed: 79 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,31 @@ import { useDateFilters } from '@/hooks/use-date-filters';
1212
import { addDynamicFilterAtom } from '@/stores/jotai/filterAtoms';
1313
import { AddFilterForm } from './utils/add-filters';
1414

15+
const MAX_HOURLY_DAYS = 7;
16+
17+
type QuickRange = {
18+
label: string;
19+
fullLabel: string;
20+
hours?: number;
21+
days?: number;
22+
};
23+
24+
const QUICK_RANGES: QuickRange[] = [
25+
{ label: '24h', fullLabel: 'Last 24 hours', hours: 24 },
26+
{ label: '7d', fullLabel: 'Last 7 days', days: 7 },
27+
{ label: '30d', fullLabel: 'Last 30 days', days: 30 },
28+
{ label: '90d', fullLabel: 'Last 90 days', days: 90 },
29+
{ label: '180d', fullLabel: 'Last 180 days', days: 180 },
30+
{ label: '365d', fullLabel: 'Last 365 days', days: 365 },
31+
];
32+
33+
const getStartDateForRange = (range: QuickRange) => {
34+
const now = new Date();
35+
return range.hours
36+
? dayjs(now).subtract(range.hours, 'hour').toDate()
37+
: dayjs(now).subtract(range.days ?? 7, 'day').toDate();
38+
};
39+
1540
interface AnalyticsToolbarProps {
1641
isRefreshing: boolean;
1742
onRefresh: () => void;
@@ -32,45 +57,68 @@ export function AnalyticsToolbar({
3257

3358
const [, addFilter] = useAtom(addDynamicFilterAtom);
3459

35-
const dayPickerSelectedRange: DayPickerRange | undefined = useMemo(
60+
const dateRangeDays = useMemo(
61+
() =>
62+
dayjs(currentDateRange.endDate).diff(
63+
currentDateRange.startDate,
64+
'day'
65+
),
66+
[currentDateRange]
67+
);
68+
69+
const isHourlyDisabled = dateRangeDays > MAX_HOURLY_DAYS;
70+
71+
const selectedRange: DayPickerRange | undefined = useMemo(
3672
() => ({
3773
from: currentDateRange.startDate,
3874
to: currentDateRange.endDate,
3975
}),
4076
[currentDateRange]
4177
);
4278

43-
const quickRanges = useMemo(
44-
() => [
45-
{ label: '24h', fullLabel: 'Last 24 hours', hours: 24 },
46-
{ label: '7d', fullLabel: 'Last 7 days', days: 7 },
47-
{ label: '30d', fullLabel: 'Last 30 days', days: 30 },
48-
{ label: '90d', fullLabel: 'Last 90 days', days: 90 },
49-
{ label: '180d', fullLabel: 'Last 180 days', days: 180 },
50-
{ label: '365d', fullLabel: 'Last 365 days', days: 365 },
51-
],
52-
[]
79+
const handleQuickRangeSelect = useCallback(
80+
(range: QuickRange) => {
81+
const start = getStartDateForRange(range);
82+
setDateRangeAction({ startDate: start, endDate: new Date() });
83+
},
84+
[setDateRangeAction]
5385
);
5486

55-
const handleQuickRangeSelect = useCallback(
56-
(range: (typeof quickRanges)[0]) => {
87+
const getGranularityButtonClass = (type: 'daily' | 'hourly') => {
88+
const isActive = currentGranularity === type;
89+
const baseClass =
90+
'h-8 cursor-pointer touch-manipulation rounded-none px-3 text-sm';
91+
const activeClass = isActive
92+
? 'bg-primary/10 font-medium text-primary'
93+
: 'text-muted-foreground';
94+
const disabledClass =
95+
type === 'hourly' && isHourlyDisabled
96+
? 'cursor-not-allowed opacity-50'
97+
: '';
98+
return `${baseClass} ${activeClass} ${disabledClass}`.trim();
99+
};
100+
101+
const isQuickRangeActive = useCallback(
102+
(range: QuickRange) => {
103+
if (!selectedRange?.from || !selectedRange?.to) return false;
104+
57105
const now = new Date();
58-
const start = range.hours
59-
? dayjs(now).subtract(range.hours, 'hour').toDate()
60-
: dayjs(now)
61-
.subtract(range.days || 7, 'day')
62-
.toDate();
63-
setDateRangeAction({ startDate: start, endDate: now });
106+
const start = getStartDateForRange(range);
107+
108+
return (
109+
dayjs(selectedRange.from).isSame(start, 'day') &&
110+
dayjs(selectedRange.to).isSame(now, 'day')
111+
);
64112
},
65-
[setDateRangeAction]
113+
[selectedRange]
66114
);
67115

68116
return (
69117
<div className="mt-3 flex flex-col gap-2 rounded border bg-card p-3 shadow-sm">
70118
<div className="flex items-center justify-between gap-3">
71119
<div className="flex h-8 overflow-hidden rounded border bg-background shadow-sm">
72120
<Button
73-
className={`h-8 cursor-pointer touch-manipulation rounded-none px-3 text-sm ${currentGranularity === 'daily' ? 'bg-primary/10 font-medium text-primary' : 'text-muted-foreground'}`}
121+
className={getGranularityButtonClass('daily')}
74122
onClick={() => setCurrentGranularityAtomState('daily')}
75123
size="sm"
76124
title="View daily aggregated data"
@@ -79,10 +127,15 @@ export function AnalyticsToolbar({
79127
Daily
80128
</Button>
81129
<Button
82-
className={`h-8 cursor-pointer touch-manipulation rounded-none px-3 text-sm ${currentGranularity === 'hourly' ? 'bg-primary/10 font-medium text-primary' : 'text-muted-foreground'}`}
130+
className={getGranularityButtonClass('hourly')}
131+
disabled={isHourlyDisabled}
83132
onClick={() => setCurrentGranularityAtomState('hourly')}
84133
size="sm"
85-
title="View hourly data (best for 24h periods)"
134+
title={
135+
isHourlyDisabled
136+
? `Hourly view is only available for ${MAX_HOURLY_DAYS} days or less`
137+
: `View hourly data (up to ${MAX_HOURLY_DAYS} days)`
138+
}
86139
variant="ghost"
87140
>
88141
Hourly
@@ -109,22 +162,8 @@ export function AnalyticsToolbar({
109162
</div>
110163

111164
<div className="flex items-center gap-1 overflow-x-auto rounded border bg-background p-1 shadow-sm">
112-
{quickRanges.map((range) => {
113-
const now = new Date();
114-
const start = range.hours
115-
? dayjs(now).subtract(range.hours, 'hour').toDate()
116-
: dayjs(now)
117-
.subtract(range.days || 7, 'day')
118-
.toDate();
119-
const dayPickerCurrentRange = dayPickerSelectedRange;
120-
const isActive =
121-
dayPickerCurrentRange?.from &&
122-
dayPickerCurrentRange?.to &&
123-
dayjs(dayPickerCurrentRange.from).format('YYYY-MM-DD') ===
124-
dayjs(start).format('YYYY-MM-DD') &&
125-
dayjs(dayPickerCurrentRange.to).format('YYYY-MM-DD') ===
126-
dayjs(now).format('YYYY-MM-DD');
127-
165+
{QUICK_RANGES.map((range) => {
166+
const isActive = isQuickRangeActive(range);
128167
return (
129168
<Button
130169
className={`h-8 cursor-pointer touch-manipulation whitespace-nowrap px-2 font-medium text-xs ${isActive ? 'bg-primary/10 text-primary shadow-sm' : 'text-muted-foreground hover:text-foreground'}`}
@@ -152,7 +191,7 @@ export function AnalyticsToolbar({
152191
});
153192
}
154193
}}
155-
value={dayPickerSelectedRange}
194+
value={selectedRange}
156195
/>
157196
</div>
158197
</div>

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

Lines changed: 28 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -319,53 +319,42 @@ export function WebsiteOverviewTab({
319319
const dateTo = dayjs(dateRange.end_date);
320320
const dateDiff = dateTo.diff(dateFrom, 'day');
321321

322-
const filterFutureEvents = useCallback(
323-
(events: MetricPoint[]) => {
324-
const userTimezone = getUserTimezone();
325-
const now = dayjs().tz(userTimezone);
322+
const processedEventsData = useMemo(() => {
323+
if (!analytics.events_by_date?.length) return [];
324+
325+
const userTimezone = getUserTimezone();
326+
const now = dayjs().tz(userTimezone);
327+
const isHourly = dateRange.granularity === 'hourly';
326328

327-
return events.filter((event: MetricPoint) => {
328-
const eventDate = dayjs.utc(event.date).tz(userTimezone);
329+
// Step 1: Filter future events
330+
const filteredEvents = analytics.events_by_date.filter((event: MetricPoint) => {
331+
const eventDate = dayjs.utc(event.date).tz(userTimezone);
329332

330-
if (dateRange.granularity === 'hourly') {
331-
return eventDate.isBefore(now);
332-
}
333+
if (isHourly) {
334+
return eventDate.isBefore(now);
335+
}
333336

334-
const endOfToday = now.endOf('day');
335-
return (
336-
eventDate.isBefore(endOfToday) || eventDate.isSame(endOfToday, 'day')
337-
);
338-
});
339-
},
340-
[dateRange.granularity]
341-
);
337+
const endOfToday = now.endOf('day');
338+
return eventDate.isBefore(endOfToday) || eventDate.isSame(endOfToday, 'day');
339+
});
342340

343-
const fillMissingDates = useCallback((data: MetricPoint[]) => {
344-
const userTimezone = getUserTimezone();
345-
const isHourly = dateRange.granularity === 'hourly';
346-
347-
// Create a map of existing data for quick lookup
341+
// Step 2: Create lookup map
348342
const dataMap = new Map<string, MetricPoint>();
349-
for (const item of data) {
343+
for (const item of filteredEvents) {
350344
const key = isHourly ? item.date : item.date.slice(0, 10);
351345
dataMap.set(key, item);
352346
}
353347

354-
// Generate all dates in range
348+
// Step 3: Fill missing dates
355349
const startDate = dayjs(dateRange.start_date).tz(userTimezone);
356350
const endDate = dayjs(dateRange.end_date).tz(userTimezone);
357-
const now = dayjs().tz(userTimezone);
358-
359351
const filled: MetricPoint[] = [];
360352
let current = startDate;
361353

362354
while (current.isBefore(endDate) || current.isSame(endDate, 'day')) {
363355
if (isHourly) {
364-
// Hourly granularity - iterate through hours
365356
for (let hour = 0; hour < 24; hour++) {
366357
const hourDate = current.hour(hour);
367-
368-
// Don't add future hours
369358
if (hourDate.isAfter(now)) break;
370359

371360
const key = hourDate.format('YYYY-MM-DD HH:00:00');
@@ -383,11 +372,8 @@ export function WebsiteOverviewTab({
383372
});
384373
}
385374
current = current.add(1, 'day');
386-
387-
// If we've reached today, stop after processing current hour
388375
if (current.isAfter(endDate, 'day')) break;
389376
} else {
390-
// Daily granularity
391377
const key = current.format('YYYY-MM-DD');
392378
const existing = dataMap.get(key);
393379

@@ -407,15 +393,15 @@ export function WebsiteOverviewTab({
407393
}
408394

409395
return filled;
410-
}, [dateRange.start_date, dateRange.end_date, dateRange.granularity]);
396+
}, [
397+
analytics.events_by_date,
398+
dateRange.start_date,
399+
dateRange.end_date,
400+
dateRange.granularity,
401+
]);
411402

412403
const chartData = useMemo(() => {
413-
const filteredEvents = analytics.events_by_date?.length
414-
? filterFutureEvents(analytics.events_by_date)
415-
: [];
416-
const filledEvents = fillMissingDates(filteredEvents);
417-
418-
return filledEvents.map((event: MetricPoint): ChartDataPoint => ({
404+
return processedEventsData.map((event: MetricPoint): ChartDataPoint => ({
419405
date: formatDateByGranularity(event.date, dateRange.granularity),
420406
rawDate: event.date,
421407
...(visibleMetrics.pageviews && { pageviews: event.pageviews as number }),
@@ -428,27 +414,14 @@ export function WebsiteOverviewTab({
428414
avg_session_duration: event.avg_session_duration as number
429415
}),
430416
}));
431-
}, [
432-
analytics.events_by_date,
433-
dateRange.granularity,
434-
dateRange.start_date,
435-
dateRange.end_date,
436-
visibleMetrics,
437-
filterFutureEvents,
438-
fillMissingDates,
439-
]);
417+
}, [processedEventsData, dateRange.granularity, visibleMetrics]);
440418

441419
const miniChartData = useMemo(() => {
442-
const filteredEvents = analytics.events_by_date?.length
443-
? filterFutureEvents(analytics.events_by_date)
444-
: [];
445-
const filledEvents = fillMissingDates(filteredEvents);
446-
447420
const createChartSeries = (
448421
field: keyof MetricPoint,
449422
transform?: (value: number) => number
450423
) =>
451-
filledEvents.map((event: MetricPoint) => ({
424+
processedEventsData.map((event: MetricPoint) => ({
452425
date: dateRange.granularity === 'hourly' ? event.date : event.date.slice(0, 10),
453426
value: transform ? transform(event[field] as number) : (event[field] as number) || 0,
454427
}));
@@ -468,7 +441,7 @@ export function WebsiteOverviewTab({
468441
bounceRate: createChartSeries('bounce_rate'),
469442
sessionDuration: createChartSeries('avg_session_duration', formatSessionDuration),
470443
};
471-
}, [analytics.events_by_date, dateRange.granularity, filterFutureEvents, fillMissingDates]);
444+
}, [processedEventsData, dateRange.granularity]);
472445

473446
const createTechnologyCell = (type: 'browser' | 'os') => (info: CellInfo) => {
474447
const entry = info.row.original as TechnologyData;

0 commit comments

Comments
 (0)