Skip to content

Commit 4a594d4

Browse files
vagxrthizadoesdevcoderabbitai[bot]
authored
[UI]: revamp the filter section UI (#89)
* feat: add referrer normalization and enhance analytics toolbar with filters * refactor: update filters section to improve UI and functionality * style: update button styles in filters section for improved UI * style: adjust padding in filters section for enhanced layout * Update apps/dashboard/app/(main)/websites/[id]/_components/filters-section.tsx Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Update apps/dashboard/app/(main)/websites/[id]/_components/filters-section.tsx Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --------- Co-authored-by: iza <[email protected]> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
1 parent dde592f commit 4a594d4

File tree

5 files changed

+286
-186
lines changed

5 files changed

+286
-186
lines changed

apps/api/src/query/simple-builder.ts

Lines changed: 70 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,5 @@
11
import { chQuery } from '@databuddy/db';
2-
import {
3-
type DeviceType,
4-
mapScreenResolutionToDeviceType,
5-
} from './screen-resolution-to-device-type';
2+
import type { DeviceType } from './screen-resolution-to-device-type';
63
import type {
74
CompiledQuery,
85
Filter,
@@ -19,6 +16,48 @@ const SPECIAL_FILTER_FIELDS = {
1916
DEVICE_TYPE: 'device_type',
2017
} as const;
2118

19+
// Helper function to normalize user input for referrer filters
20+
function normalizeReferrerFilterValue(value: string): string {
21+
const lowerValue = value.toLowerCase();
22+
23+
// Map common user inputs to normalized referrer values
24+
const referrerMappings: Record<string, string> = {
25+
direct: 'direct',
26+
google: 'https://google.com',
27+
'google.com': 'https://google.com',
28+
'www.google.com': 'https://google.com',
29+
facebook: 'https://facebook.com',
30+
'facebook.com': 'https://facebook.com',
31+
'www.facebook.com': 'https://facebook.com',
32+
twitter: 'https://twitter.com',
33+
'twitter.com': 'https://twitter.com',
34+
'www.twitter.com': 'https://twitter.com',
35+
't.co': 'https://twitter.com',
36+
instagram: 'https://instagram.com',
37+
'instagram.com': 'https://instagram.com',
38+
'www.instagram.com': 'https://instagram.com',
39+
'l.instagram.com': 'https://instagram.com',
40+
};
41+
42+
// Check if the input matches any known mapping
43+
if (referrerMappings[lowerValue]) {
44+
return referrerMappings[lowerValue];
45+
}
46+
47+
// If the value already looks like a URL, return as-is
48+
if (value.startsWith('http://') || value.startsWith('https://')) {
49+
return value;
50+
}
51+
52+
// For other domains, add https:// prefix if it looks like a domain
53+
if (value.includes('.') && !value.includes(' ')) {
54+
return `https://${value}`;
55+
}
56+
57+
// Return original value if no transformation is needed
58+
return value;
59+
}
60+
2261
export class SimpleQueryBuilder {
2362
private config: SimpleQueryConfig;
2463
private request: QueryRequest;
@@ -94,7 +133,6 @@ export class SimpleQueryBuilder {
94133
return `(${aspectExpr} >= 2.0 AND ${longSideExpr} >= 2560 AND ${longSideExpr} IS NOT NULL)`;
95134
case 'watch':
96135
return `(${longSideExpr} <= 400 AND ${aspectExpr} >= 0.85 AND ${aspectExpr} <= 1.15 AND ${longSideExpr} IS NOT NULL)`;
97-
case 'unknown':
98136
default:
99137
return '1 = 0'; // Never matches
100138
}
@@ -149,6 +187,7 @@ export class SimpleQueryBuilder {
149187
if (filter.field === SPECIAL_FILTER_FIELDS.REFERRER) {
150188
const normalizedReferrerExpression =
151189
'CASE ' +
190+
"WHEN referrer = '' OR referrer IS NULL THEN 'direct' " +
152191
"WHEN domain(referrer) LIKE '%.google.com%' OR domain(referrer) LIKE 'google.com%' THEN 'https://google.com' " +
153192
"WHEN domain(referrer) LIKE '%.facebook.com%' OR domain(referrer) LIKE 'facebook.com%' THEN 'https://facebook.com' " +
154193
"WHEN domain(referrer) LIKE '%.twitter.com%' OR domain(referrer) LIKE 'twitter.com%' OR domain(referrer) LIKE 't.co%' THEN 'https://twitter.com' " +
@@ -157,25 +196,47 @@ export class SimpleQueryBuilder {
157196
'END';
158197

159198
if (filter.op === 'like') {
199+
// For 'like' operations, we need to handle user-friendly input
200+
// If user types "Google", they want to match referrers like 'https://google.com'
201+
const lowerValue = String(filter.value).toLowerCase();
202+
let searchValue = filter.value;
203+
204+
// Map common search terms to more specific patterns
205+
if (lowerValue === 'direct') {
206+
searchValue = 'direct';
207+
} else if (lowerValue === 'google') {
208+
searchValue = 'google.com';
209+
} else if (lowerValue === 'facebook') {
210+
searchValue = 'facebook.com';
211+
} else if (lowerValue === 'twitter') {
212+
searchValue = 'twitter.com';
213+
} else if (lowerValue === 'instagram') {
214+
searchValue = 'instagram.com';
215+
}
216+
160217
return {
161218
clause: `${normalizedReferrerExpression} ${operator} {${key}:String}`,
162-
params: { [key]: `%${filter.value}%` },
219+
params: { [key]: `%${searchValue}%` },
163220
};
164221
}
165222

166223
if (filter.op === 'in' || filter.op === 'notIn') {
167224
const values = Array.isArray(filter.value)
168-
? filter.value
169-
: [filter.value];
225+
? filter.value.map((v) => normalizeReferrerFilterValue(String(v)))
226+
: [normalizeReferrerFilterValue(String(filter.value))];
170227
return {
171228
clause: `${normalizedReferrerExpression} ${operator} {${key}:Array(String)}`,
172229
params: { [key]: values },
173230
};
174231
}
175232

233+
// For exact matches (eq, ne), normalize the user input to match the normalized referrer expression
234+
const normalizedValue = normalizeReferrerFilterValue(
235+
String(filter.value)
236+
);
176237
return {
177238
clause: `${normalizedReferrerExpression} ${operator} {${key}:String}`,
178-
params: { [key]: filter.value },
239+
params: { [key]: normalizedValue },
179240
};
180241
}
181242

Lines changed: 80 additions & 152 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,22 @@
11
'use client';
22

3-
import { type DynamicQueryFilter, filterOptions } from '@databuddy/shared';
4-
import { ArrowClockwiseIcon, XIcon } from '@phosphor-icons/react';
3+
import { ArrowClockwiseIcon } from '@phosphor-icons/react';
54
import dayjs from 'dayjs';
65
import { useCallback, useMemo } from 'react';
76
import type { DateRange as DayPickerRange } from 'react-day-picker';
87
import { DateRangePicker } from '@/components/date-range-picker';
98
import { Button } from '@/components/ui/button';
109
import { useDateFilters } from '@/hooks/use-date-filters';
11-
import { operatorOptions, useFilters } from '@/hooks/use-filters';
12-
import { AddFilterForm, getOperatorShorthand } from './utils/add-filters';
1310

1411
interface AnalyticsToolbarProps {
1512
isRefreshing: boolean;
1613
onRefresh: () => void;
17-
selectedFilters: DynamicQueryFilter[];
18-
onFiltersChange: (filters: DynamicQueryFilter[]) => void;
1914
}
2015

2116
export function AnalyticsToolbar({
2217
isRefreshing,
2318
onRefresh,
24-
selectedFilters,
25-
onFiltersChange,
2619
}: AnalyticsToolbarProps) {
27-
const { addFilter, removeFilter } = useFilters({
28-
filters: selectedFilters,
29-
onFiltersChange,
30-
});
31-
3220
const {
3321
currentDateRange,
3422
currentGranularity,
@@ -70,153 +58,93 @@ export function AnalyticsToolbar({
7058
);
7159

7260
return (
73-
<>
74-
<div className="mt-3 flex flex-col gap-3 rounded-lg border bg-muted/30 p-2.5">
75-
<div className="flex items-center justify-between gap-3">
76-
<div className="flex h-8 overflow-hidden rounded-md border bg-background shadow-sm">
77-
<Button
78-
className={`h-8 cursor-pointer touch-manipulation rounded-none px-2 text-xs sm:px-3 ${currentGranularity === 'daily' ? 'bg-primary/10 font-medium text-primary' : 'text-muted-foreground'}`}
79-
onClick={() => setCurrentGranularityAtomState('daily')}
80-
size="sm"
81-
title="View daily aggregated data"
82-
variant="ghost"
83-
>
84-
Daily
85-
</Button>
86-
<Button
87-
className={`h-8 cursor-pointer touch-manipulation rounded-none px-2 text-xs sm:px-3 ${currentGranularity === 'hourly' ? 'bg-primary/10 font-medium text-primary' : 'text-muted-foreground'}`}
88-
onClick={() => setCurrentGranularityAtomState('hourly')}
89-
size="sm"
90-
title="View hourly data (best for 24h periods)"
91-
variant="ghost"
92-
>
93-
Hourly
94-
</Button>
95-
</div>
96-
61+
<div className="mt-3 flex flex-col gap-3 rounded border bg-card p-4 shadow-sm">
62+
<div className="flex items-center justify-between gap-3">
63+
<div className="flex h-8 overflow-hidden rounded-md border bg-background shadow-sm">
9764
<Button
98-
aria-label="Refresh data"
99-
className="h-8 w-8"
100-
disabled={isRefreshing}
101-
onClick={onRefresh}
102-
size="icon"
103-
variant="outline"
65+
className={`h-8 cursor-pointer touch-manipulation rounded-none px-2 text-xs sm:px-3 ${currentGranularity === 'daily' ? 'bg-primary/10 font-medium text-primary' : 'text-muted-foreground'}`}
66+
onClick={() => setCurrentGranularityAtomState('daily')}
67+
size="sm"
68+
title="View daily aggregated data"
69+
variant="ghost"
10470
>
105-
<ArrowClockwiseIcon
106-
aria-hidden="true"
107-
className={`h-4 w-4 ${isRefreshing ? 'animate-spin' : ''}`}
108-
/>
71+
Daily
72+
</Button>
73+
<Button
74+
className={`h-8 cursor-pointer touch-manipulation rounded-none px-2 text-xs sm:px-3 ${currentGranularity === 'hourly' ? 'bg-primary/10 font-medium text-primary' : 'text-muted-foreground'}`}
75+
onClick={() => setCurrentGranularityAtomState('hourly')}
76+
size="sm"
77+
title="View hourly data (best for 24h periods)"
78+
variant="ghost"
79+
>
80+
Hourly
10981
</Button>
11082
</div>
11183

112-
<div className="flex items-center gap-2 overflow-x-auto rounded-md border bg-background p-1 shadow-sm">
113-
{quickRanges.map((range) => {
114-
const now = new Date();
115-
const start = range.hours
116-
? dayjs(now).subtract(range.hours, 'hour').toDate()
117-
: dayjs(now)
118-
.subtract(range.days || 7, 'day')
119-
.toDate();
120-
const dayPickerCurrentRange = dayPickerSelectedRange;
121-
const isActive =
122-
dayPickerCurrentRange?.from &&
123-
dayPickerCurrentRange?.to &&
124-
dayjs(dayPickerCurrentRange.from).format('YYYY-MM-DD') ===
125-
dayjs(start).format('YYYY-MM-DD') &&
126-
dayjs(dayPickerCurrentRange.to).format('YYYY-MM-DD') ===
127-
dayjs(now).format('YYYY-MM-DD');
128-
129-
return (
130-
<Button
131-
className={`h-6 cursor-pointer touch-manipulation whitespace-nowrap px-2 text-xs sm:px-2.5 ${isActive ? 'shadow-sm' : 'text-muted-foreground hover:text-foreground'}`}
132-
key={range.label}
133-
onClick={() => handleQuickRangeSelect(range)}
134-
size="sm"
135-
title={range.fullLabel}
136-
variant={isActive ? 'default' : 'ghost'}
137-
>
138-
<span className="sm:hidden">{range.label}</span>
139-
<span className="hidden sm:inline">{range.fullLabel}</span>
140-
</Button>
141-
);
142-
})}
143-
144-
<div className="ml-1 border-border/50 border-l pl-2 sm:pl-3">
145-
<DateRangePicker
146-
className="w-auto"
147-
maxDate={new Date()}
148-
minDate={new Date(2020, 0, 1)}
149-
onChange={(range) => {
150-
if (range?.from && range?.to) {
151-
setDateRangeAction({
152-
startDate: range.from,
153-
endDate: range.to,
154-
});
155-
}
156-
}}
157-
value={dayPickerSelectedRange}
158-
/>
159-
</div>
160-
161-
<div className="ml-2 flex items-center">
162-
<AddFilterForm addFilter={addFilter} />
163-
</div>
164-
</div>
84+
<Button
85+
aria-label="Refresh data"
86+
className="h-8 w-8"
87+
disabled={isRefreshing}
88+
onClick={onRefresh}
89+
size="icon"
90+
variant="outline"
91+
>
92+
<ArrowClockwiseIcon
93+
aria-hidden="true"
94+
className={`h-4 w-4 ${isRefreshing ? 'animate-spin' : ''}`}
95+
/>
96+
</Button>
16597
</div>
16698

167-
{selectedFilters.length > 0 && (
168-
<div className="mt-3 rounded-lg border bg-muted/30 p-2.5">
169-
<div className="flex items-center justify-between gap-3">
170-
<div className="flex items-center gap-2 overflow-x-auto">
171-
<div className="font-semibold text-sm">Filters</div>
172-
<div className="flex flex-wrap items-center gap-2">
173-
{selectedFilters.map((filter, index) => {
174-
const fieldLabel = filterOptions.find(
175-
(o) => o.value === filter.field
176-
)?.label;
177-
const operatorLabel = operatorOptions.find(
178-
(o) => getOperatorShorthand(o.value) === filter.operator
179-
)?.label;
180-
const valueLabel = Array.isArray(filter.value)
181-
? filter.value.join(', ')
182-
: filter.value;
183-
184-
return (
185-
<div
186-
className="flex items-center gap-0 rounded border bg-background py-1 pr-2 pl-3 shadow-sm"
187-
key={`filter-${index}-${filter.field}-${filter.operator}`}
188-
>
189-
<div className="flex items-center gap-1">
190-
<span className="font-medium text-foreground text-sm">
191-
{fieldLabel}
192-
</span>
193-
<span className="text-muted-foreground/70 text-sm">
194-
{operatorLabel}
195-
</span>
196-
<span className="font-medium text-foreground text-sm">
197-
{valueLabel}
198-
</span>
199-
</div>
200-
<button
201-
aria-label={`Remove filter ${fieldLabel} ${operatorLabel} ${valueLabel}`}
202-
className="flex h-6 w-6 items-center justify-center rounded hover:bg-muted/50"
203-
onClick={() => removeFilter(index)}
204-
type="button"
205-
>
206-
<XIcon aria-hidden="true" className="h-3 w-3" />
207-
</button>
208-
</div>
209-
);
210-
})}
211-
</div>
212-
</div>
213-
214-
<Button onClick={() => onFiltersChange([])} variant="outline">
215-
Clear all filters
99+
<div className="flex items-center gap-2 overflow-x-auto rounded-md border bg-background p-1 shadow-sm">
100+
{quickRanges.map((range) => {
101+
const now = new Date();
102+
const start = range.hours
103+
? dayjs(now).subtract(range.hours, 'hour').toDate()
104+
: dayjs(now)
105+
.subtract(range.days || 7, 'day')
106+
.toDate();
107+
const dayPickerCurrentRange = dayPickerSelectedRange;
108+
const isActive =
109+
dayPickerCurrentRange?.from &&
110+
dayPickerCurrentRange?.to &&
111+
dayjs(dayPickerCurrentRange.from).format('YYYY-MM-DD') ===
112+
dayjs(start).format('YYYY-MM-DD') &&
113+
dayjs(dayPickerCurrentRange.to).format('YYYY-MM-DD') ===
114+
dayjs(now).format('YYYY-MM-DD');
115+
116+
return (
117+
<Button
118+
className={`h-6 cursor-pointer touch-manipulation whitespace-nowrap px-2 text-xs sm:px-2.5 ${isActive ? 'shadow-sm' : 'text-muted-foreground hover:text-foreground'}`}
119+
key={range.label}
120+
onClick={() => handleQuickRangeSelect(range)}
121+
size="sm"
122+
title={range.fullLabel}
123+
variant={isActive ? 'default' : 'ghost'}
124+
>
125+
<span className="sm:hidden">{range.label}</span>
126+
<span className="hidden sm:inline">{range.fullLabel}</span>
216127
</Button>
217-
</div>
128+
);
129+
})}
130+
131+
<div className="ml-1 border-border/50 border-l pl-2 sm:pl-3">
132+
<DateRangePicker
133+
className="w-auto"
134+
maxDate={new Date()}
135+
minDate={new Date(2020, 0, 1)}
136+
onChange={(range) => {
137+
if (range?.from && range?.to) {
138+
setDateRangeAction({
139+
startDate: range.from,
140+
endDate: range.to,
141+
});
142+
}
143+
}}
144+
value={dayPickerSelectedRange}
145+
/>
218146
</div>
219-
)}
220-
</>
147+
</div>
148+
</div>
221149
);
222150
}

0 commit comments

Comments
 (0)