Skip to content

Commit f579a53

Browse files
committed
feat: shared analytics toolbar, filters and better handling in sessions page
1 parent 79adc87 commit f579a53

File tree

15 files changed

+596
-453
lines changed

15 files changed

+596
-453
lines changed

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/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+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
'use client';
2+
3+
import { useQueryClient } from '@tanstack/react-query';
4+
import { useAtom } from 'jotai';
5+
import { useParams } from 'next/navigation';
6+
import { toast } from 'sonner';
7+
import { useTrackingSetup } from '@/hooks/use-tracking-setup';
8+
import {
9+
dynamicQueryFiltersAtom,
10+
isAnalyticsRefreshingAtom,
11+
} from '@/stores/jotai/filterAtoms';
12+
import { AnalyticsToolbar } from './_components/analytics-toolbar';
13+
14+
interface WebsiteLayoutProps {
15+
children: React.ReactNode;
16+
}
17+
18+
export default function WebsiteLayout({ children }: WebsiteLayoutProps) {
19+
const { id } = useParams();
20+
const queryClient = useQueryClient();
21+
const { isTrackingSetup } = useTrackingSetup(id as string);
22+
const [isRefreshing, setIsRefreshing] = useAtom(isAnalyticsRefreshingAtom);
23+
const [selectedFilters, setSelectedFilters] = useAtom(
24+
dynamicQueryFiltersAtom
25+
);
26+
27+
const handleRefresh = async () => {
28+
setIsRefreshing(true);
29+
try {
30+
await Promise.all([
31+
queryClient.invalidateQueries({ queryKey: ['websites', id] }),
32+
queryClient.invalidateQueries({
33+
queryKey: ['websites', 'isTrackingSetup', id],
34+
}),
35+
queryClient.invalidateQueries({ queryKey: ['dynamic-query', id] }),
36+
queryClient.invalidateQueries({
37+
queryKey: ['batch-dynamic-query', id],
38+
}),
39+
]);
40+
toast.success('Data refreshed');
41+
} catch {
42+
toast.error('Failed to refresh data');
43+
} finally {
44+
setIsRefreshing(false);
45+
}
46+
};
47+
48+
return (
49+
<div className="mx-auto max-w-[1600px] p-3 sm:p-4 lg:p-6">
50+
{/* Analytics toolbar shared across all website pages */}
51+
{isTrackingSetup && (
52+
<AnalyticsToolbar
53+
isRefreshing={isRefreshing}
54+
onFiltersChange={setSelectedFilters}
55+
onRefresh={handleRefresh}
56+
selectedFilters={selectedFilters}
57+
/>
58+
)}
59+
60+
{/* Page content */}
61+
{children}
62+
</div>
63+
);
64+
}

0 commit comments

Comments
 (0)