Skip to content

Commit 8c18071

Browse files
alex-holovachizadoesdevgithub-actions[bot]coderabbitai[bot]
authored
Add table filter integration: types, getFilter mappings, DataTable on… (#86)
* feat: shared analytics toolbar, filters and better handling in sessions page * Add table filter integration: types, getFilter mappings, DataTable onAddFilter wiring * Apply suggestion from @coderabbitai[bot] Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * cleanup * fix: cleanup page and referrer handling logic to process filters properly --------- Co-authored-by: Hyteq <[email protected]> Co-authored-by: github-actions[bot] <github-actions[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 79adc87 commit 8c18071

File tree

23 files changed

+878
-467
lines changed

23 files changed

+878
-467
lines changed

apps/api/src/query/builders/pages.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export const PagesBuilders: Record<string, SimpleQueryConfig> = {
1818
orderBy: 'pageviews DESC',
1919
limit: 100,
2020
timeField: 'time',
21+
allowedFilters: ['path', 'country', 'device_type', 'browser_name', 'os_name', 'referrer', 'utm_source', 'utm_medium', 'utm_campaign'],
2122
customizable: true,
2223
meta: {
2324
title: 'Top Pages',
@@ -67,6 +68,8 @@ export const PagesBuilders: Record<string, SimpleQueryConfig> = {
6768
},
6869

6970
entry_pages: {
71+
allowedFilters: ['path', 'country', 'device_type', 'browser_name', 'os_name', 'referrer', 'utm_source', 'utm_medium', 'utm_campaign'],
72+
customizable: true,
7073
customSql: (
7174
websiteId: string,
7275
startDate: string,
@@ -121,6 +124,8 @@ export const PagesBuilders: Record<string, SimpleQueryConfig> = {
121124
},
122125

123126
exit_pages: {
127+
allowedFilters: ['path', 'country', 'device_type', 'browser_name', 'os_name', 'referrer', 'utm_source', 'utm_medium', 'utm_campaign'],
128+
customizable: true,
124129
customSql: (
125130
websiteId: string,
126131
startDate: string,
@@ -199,6 +204,7 @@ export const PagesBuilders: Record<string, SimpleQueryConfig> = {
199204
orderBy: 'pageviews DESC',
200205
limit: 100,
201206
timeField: 'time',
207+
allowedFilters: ['path', 'country', 'device_type', 'browser_name', 'os_name', 'referrer', 'utm_source', 'utm_medium', 'utm_campaign'],
202208
customizable: true,
203209
},
204210
};

apps/api/src/query/builders/sessions.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,6 @@ export const SessionsBuilders: Record<string, SimpleQueryConfig> = {
134134
client_id = {websiteId:String}
135135
AND time >= parseDateTimeBestEffort({startDate:String})
136136
AND time <= parseDateTimeBestEffort({endDate:String})
137-
${combinedWhereClause}
138137
GROUP BY session_id
139138
ORDER BY first_visit DESC
140139
LIMIT {limit:Int32} OFFSET {offset:Int32}
@@ -181,6 +180,7 @@ export const SessionsBuilders: Record<string, SimpleQueryConfig> = {
181180
COALESCE(se.events, []) as events
182181
FROM session_list sl
183182
LEFT JOIN session_events se ON sl.session_id = se.session_id
183+
${combinedWhereClause ? `WHERE ${combinedWhereClause.replace('AND ', '')}` : ''}
184184
ORDER BY sl.first_visit DESC
185185
`,
186186
params: {
@@ -193,6 +193,9 @@ export const SessionsBuilders: Record<string, SimpleQueryConfig> = {
193193
},
194194
};
195195
},
196+
plugins: {
197+
normalizeGeo: true,
198+
},
196199
},
197200

198201
session_events: {

apps/api/src/query/builders/traffic.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ export const TrafficBuilders: Record<string, SimpleQueryConfig> = {
6565
orderBy: 'pageviews DESC',
6666
limit: 100,
6767
timeField: 'time',
68+
allowedFilters: ['path', 'country', 'device_type', 'browser_name', 'os_name', 'referrer', 'utm_source', 'utm_medium', 'utm_campaign'],
6869
customizable: true,
6970
plugins: { parseReferrers: true },
7071
},
@@ -119,6 +120,7 @@ export const TrafficBuilders: Record<string, SimpleQueryConfig> = {
119120
orderBy: 'pageviews DESC',
120121
limit: 100,
121122
timeField: 'time',
123+
allowedFilters: ['path', 'country', 'device_type', 'browser_name', 'os_name', 'referrer', 'utm_source', 'utm_medium', 'utm_campaign'],
122124
customizable: true,
123125
},
124126

@@ -135,6 +137,7 @@ export const TrafficBuilders: Record<string, SimpleQueryConfig> = {
135137
orderBy: 'pageviews DESC',
136138
limit: 100,
137139
timeField: 'time',
140+
allowedFilters: ['path', 'country', 'device_type', 'browser_name', 'os_name', 'referrer', 'utm_source', 'utm_medium', 'utm_campaign'],
138141
customizable: true,
139142
},
140143

@@ -188,6 +191,7 @@ export const TrafficBuilders: Record<string, SimpleQueryConfig> = {
188191
orderBy: 'pageviews DESC',
189192
limit: 100,
190193
timeField: 'time',
194+
allowedFilters: ['path', 'country', 'device_type', 'browser_name', 'os_name', 'referrer', 'utm_source', 'utm_medium', 'utm_campaign'],
191195
customizable: true,
192196
},
193197

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

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,67 @@ export class SimpleQueryBuilder {
3333
const key = `f${index}`;
3434
const operator = FilterOperators[filter.op];
3535

36+
// Special handling for path filters - apply same normalization as used in queries
37+
if (filter.field === 'path') {
38+
const normalizedPathExpression = "CASE WHEN trimRight(path(path), '/') = '' THEN '/' ELSE trimRight(path(path), '/') END";
39+
40+
if (filter.op === 'like') {
41+
return {
42+
clause: `${normalizedPathExpression} ${operator} {${key}:String}`,
43+
params: { [key]: `%${filter.value}%` },
44+
};
45+
}
46+
47+
if (filter.op === 'in' || filter.op === 'notIn') {
48+
const values = Array.isArray(filter.value)
49+
? filter.value
50+
: [filter.value];
51+
return {
52+
clause: `${normalizedPathExpression} ${operator} {${key}:Array(String)}`,
53+
params: { [key]: values },
54+
};
55+
}
56+
57+
return {
58+
clause: `${normalizedPathExpression} ${operator} {${key}:String}`,
59+
params: { [key]: filter.value },
60+
};
61+
}
62+
63+
// Special handling for referrer filters - apply same normalization as used in queries
64+
if (filter.field === 'referrer') {
65+
const normalizedReferrerExpression =
66+
'CASE ' +
67+
"WHEN domain(referrer) LIKE '%.google.com%' OR domain(referrer) LIKE 'google.com%' THEN 'https://google.com' " +
68+
"WHEN domain(referrer) LIKE '%.facebook.com%' OR domain(referrer) LIKE 'facebook.com%' THEN 'https://facebook.com' " +
69+
"WHEN domain(referrer) LIKE '%.twitter.com%' OR domain(referrer) LIKE 'twitter.com%' OR domain(referrer) LIKE 't.co%' THEN 'https://twitter.com' " +
70+
"WHEN domain(referrer) LIKE '%.instagram.com%' OR domain(referrer) LIKE 'instagram.com%' OR domain(referrer) LIKE 'l.instagram.com%' THEN 'https://instagram.com' " +
71+
"ELSE concat('https://', domain(referrer)) " +
72+
'END';
73+
74+
if (filter.op === 'like') {
75+
return {
76+
clause: `${normalizedReferrerExpression} ${operator} {${key}:String}`,
77+
params: { [key]: `%${filter.value}%` },
78+
};
79+
}
80+
81+
if (filter.op === 'in' || filter.op === 'notIn') {
82+
const values = Array.isArray(filter.value)
83+
? filter.value
84+
: [filter.value];
85+
return {
86+
clause: `${normalizedReferrerExpression} ${operator} {${key}:Array(String)}`,
87+
params: { [key]: values },
88+
};
89+
}
90+
91+
return {
92+
clause: `${normalizedReferrerExpression} ${operator} {${key}:String}`,
93+
params: { [key]: filter.value },
94+
};
95+
}
96+
3697
if (filter.op === 'like') {
3798
return {
3899
clause: `${filter.field} ${operator} {${key}:String}`,

apps/api/src/query/utils.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,7 @@ function applyReferrerParsing(
190190

191191
function applyGeoNormalization(data: DataRow[]): DataRow[] {
192192
return data.map((row) => {
193-
const currentName = getString(row.name);
193+
const currentName = getString(row.name) || getString(row.country);
194194
if (!currentName) {
195195
return row;
196196
}
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
'use client';
2+
3+
import { type DynamicQueryFilter, filterOptions } from '@databuddy/shared';
4+
import { ArrowClockwiseIcon, XIcon } from '@phosphor-icons/react';
5+
import dayjs from 'dayjs';
6+
import { useAtom } from 'jotai';
7+
import { useCallback, useMemo } from 'react';
8+
import type { DateRange as DayPickerRange } from 'react-day-picker';
9+
import { DateRangePicker } from '@/components/date-range-picker';
10+
import { Button } from '@/components/ui/button';
11+
import { operatorOptions, useFilters } from '@/hooks/use-filters';
12+
import {
13+
dateRangeAtom,
14+
setDateRangeAndAdjustGranularityAtom,
15+
timeGranularityAtom,
16+
} from '@/stores/jotai/filterAtoms';
17+
import { AddFilterForm, getOperatorShorthand } from './utils/add-filters';
18+
19+
interface AnalyticsToolbarProps {
20+
isRefreshing: boolean;
21+
onRefresh: () => void;
22+
selectedFilters: DynamicQueryFilter[];
23+
onFiltersChange: (filters: DynamicQueryFilter[]) => void;
24+
}
25+
26+
export function AnalyticsToolbar({
27+
isRefreshing,
28+
onRefresh,
29+
selectedFilters,
30+
onFiltersChange,
31+
}: AnalyticsToolbarProps) {
32+
const [currentDateRange] = useAtom(dateRangeAtom);
33+
const [currentGranularity, setCurrentGranularityAtomState] =
34+
useAtom(timeGranularityAtom);
35+
const [, setDateRangeAction] = useAtom(setDateRangeAndAdjustGranularityAtom);
36+
37+
const { addFilter, removeFilter } = useFilters({
38+
filters: selectedFilters,
39+
onFiltersChange,
40+
});
41+
42+
const dayPickerSelectedRange: DayPickerRange | undefined = useMemo(
43+
() => ({
44+
from: currentDateRange.startDate,
45+
to: currentDateRange.endDate,
46+
}),
47+
[currentDateRange]
48+
);
49+
50+
const quickRanges = useMemo(
51+
() => [
52+
{ label: '24h', fullLabel: 'Last 24 hours', hours: 24 },
53+
{ label: '7d', fullLabel: 'Last 7 days', days: 7 },
54+
{ label: '30d', fullLabel: 'Last 30 days', days: 30 },
55+
{ label: '90d', fullLabel: 'Last 90 days', days: 90 },
56+
{ label: '180d', fullLabel: 'Last 180 days', days: 180 },
57+
{ label: '365d', fullLabel: 'Last 365 days', days: 365 },
58+
],
59+
[]
60+
);
61+
62+
const handleQuickRangeSelect = useCallback(
63+
(range: (typeof quickRanges)[0]) => {
64+
const now = new Date();
65+
const start = range.hours
66+
? dayjs(now).subtract(range.hours, 'hour').toDate()
67+
: dayjs(now)
68+
.subtract(range.days || 7, 'day')
69+
.toDate();
70+
setDateRangeAction({ startDate: start, endDate: now });
71+
},
72+
[setDateRangeAction]
73+
);
74+
75+
return (
76+
<>
77+
<div className="mt-3 flex flex-col gap-3 rounded-lg border bg-muted/30 p-2.5">
78+
<div className="flex items-center justify-between gap-3">
79+
<div className="flex h-8 overflow-hidden rounded-md border bg-background shadow-sm">
80+
<Button
81+
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'}`}
82+
onClick={() => setCurrentGranularityAtomState('daily')}
83+
size="sm"
84+
title="View daily aggregated data"
85+
variant="ghost"
86+
>
87+
Daily
88+
</Button>
89+
<Button
90+
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'}`}
91+
onClick={() => setCurrentGranularityAtomState('hourly')}
92+
size="sm"
93+
title="View hourly data (best for 24h periods)"
94+
variant="ghost"
95+
>
96+
Hourly
97+
</Button>
98+
</div>
99+
100+
<Button
101+
aria-label="Refresh data"
102+
className="h-8 w-8"
103+
disabled={isRefreshing}
104+
onClick={onRefresh}
105+
size="icon"
106+
variant="outline"
107+
>
108+
<ArrowClockwiseIcon
109+
aria-hidden="true"
110+
className={`h-4 w-4 ${isRefreshing ? 'animate-spin' : ''}`}
111+
/>
112+
</Button>
113+
</div>
114+
115+
<div className="flex items-center gap-2 overflow-x-auto rounded-md border bg-background p-1 shadow-sm">
116+
{quickRanges.map((range) => {
117+
const now = new Date();
118+
const start = range.hours
119+
? dayjs(now).subtract(range.hours, 'hour').toDate()
120+
: dayjs(now)
121+
.subtract(range.days || 7, 'day')
122+
.toDate();
123+
const dayPickerCurrentRange = dayPickerSelectedRange;
124+
const isActive =
125+
dayPickerCurrentRange?.from &&
126+
dayPickerCurrentRange?.to &&
127+
dayjs(dayPickerCurrentRange.from).format('YYYY-MM-DD') ===
128+
dayjs(start).format('YYYY-MM-DD') &&
129+
dayjs(dayPickerCurrentRange.to).format('YYYY-MM-DD') ===
130+
dayjs(now).format('YYYY-MM-DD');
131+
132+
return (
133+
<Button
134+
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'}`}
135+
key={range.label}
136+
onClick={() => handleQuickRangeSelect(range)}
137+
size="sm"
138+
title={range.fullLabel}
139+
variant={isActive ? 'default' : 'ghost'}
140+
>
141+
<span className="sm:hidden">{range.label}</span>
142+
<span className="hidden sm:inline">{range.fullLabel}</span>
143+
</Button>
144+
);
145+
})}
146+
147+
<div className="ml-1 border-border/50 border-l pl-2 sm:pl-3">
148+
<DateRangePicker
149+
className="w-auto"
150+
maxDate={new Date()}
151+
minDate={new Date(2020, 0, 1)}
152+
onChange={(range) => {
153+
if (range?.from && range?.to) {
154+
setDateRangeAction({
155+
startDate: range.from,
156+
endDate: range.to,
157+
});
158+
}
159+
}}
160+
value={dayPickerSelectedRange}
161+
/>
162+
</div>
163+
164+
<div className="ml-2 flex items-center">
165+
<AddFilterForm addFilter={addFilter} />
166+
</div>
167+
</div>
168+
</div>
169+
170+
{selectedFilters.length > 0 && (
171+
<div className="mt-3 rounded-lg border bg-muted/30 p-2.5">
172+
<div className="flex items-center justify-between gap-3">
173+
<div className="flex items-center gap-2 overflow-x-auto">
174+
<div className="font-semibold text-sm">Filters</div>
175+
<div className="flex flex-wrap items-center gap-2">
176+
{selectedFilters.map((filter, index) => {
177+
const fieldLabel = filterOptions.find(
178+
(o) => o.value === filter.field
179+
)?.label;
180+
const operatorLabel = operatorOptions.find(
181+
(o) => getOperatorShorthand(o.value) === filter.operator
182+
)?.label;
183+
const valueLabel = Array.isArray(filter.value)
184+
? filter.value.join(', ')
185+
: filter.value;
186+
187+
return (
188+
<div
189+
className="flex items-center gap-0 rounded border bg-background py-1 pr-2 pl-3 shadow-sm"
190+
key={`filter-${index}-${filter.field}-${filter.operator}`}
191+
>
192+
<div className="flex items-center gap-1">
193+
<span className="font-medium text-foreground text-sm">
194+
{fieldLabel}
195+
</span>
196+
<span className="text-muted-foreground/70 text-sm">
197+
{operatorLabel}
198+
</span>
199+
<span className="font-medium text-foreground text-sm">
200+
{valueLabel}
201+
</span>
202+
</div>
203+
<button
204+
aria-label={`Remove filter ${fieldLabel} ${operatorLabel} ${valueLabel}`}
205+
className="flex h-6 w-6 items-center justify-center rounded hover:bg-muted/50"
206+
onClick={() => removeFilter(index)}
207+
type="button"
208+
>
209+
<XIcon aria-hidden="true" className="h-3 w-3" />
210+
</button>
211+
</div>
212+
);
213+
})}
214+
</div>
215+
</div>
216+
217+
<Button onClick={() => onFiltersChange([])} variant="outline">
218+
Clear all filters
219+
</Button>
220+
</div>
221+
</div>
222+
)}
223+
</>
224+
);
225+
}

0 commit comments

Comments
 (0)