{
- if ((e.target as HTMLElement).closest('a, button')) return;
+ if ((e.target as HTMLElement).closest('a, button')) {
+ return;
+ }
onRowClick(row);
}
: undefined
}
+ ref={virtualRow.measureElement}
style={{
transform: `translateY(${virtualRow.start - scrollMargin}px)`,
display: 'grid',
@@ -127,8 +133,8 @@ const VirtualRow = memo(
const width = `${cell.column.getSize()}px`;
return (
{/* Table Header */}
)}
@@ -237,21 +243,23 @@ const VirtualizedSessionsTable = ({
>
{virtualRows.map((virtualRow) => {
const row = table.getRowModel().rows[virtualRow.index];
- if (!row) return null;
+ if (!row) {
+ return null;
+ }
return (
);
})}
@@ -269,11 +277,11 @@ export const SessionsTable = ({ query }: Props) => {
const handleRowClick = useCallback(
(row: any) => {
navigate({
- to: '/$organizationId/$projectId/sessions/$sessionId' as any,
+ to: '/$organizationId/$projectId/sessions/$sessionId',
params: { organizationId, projectId, sessionId: row.original.id },
});
},
- [navigate, organizationId, projectId],
+ [navigate, organizationId, projectId]
);
const data = useMemo(() => {
@@ -281,7 +289,7 @@ export const SessionsTable = ({ query }: Props) => {
return LOADING_DATA;
}
- return query.data?.pages?.flatMap((p) => p.data) ?? [];
+ return query.data?.pages?.flatMap((p) => p.items) ?? [];
}, [query.data]);
// const { setPage, state: pagination } = useDataTablePagination();
@@ -322,7 +330,6 @@ export const SessionsTable = ({ query }: Props) => {
enterCount > 0 &&
query.isFetchingNextPage === false
) {
- console.log('fetching next page');
query.fetchNextPage();
}
}, [inViewport, enterCount, hasNextPage]);
@@ -331,16 +338,16 @@ export const SessionsTable = ({ query }: Props) => {
<>
-
+
@@ -350,15 +357,88 @@ export const SessionsTable = ({ query }: Props) => {
);
};
+const SESSION_FILTER_KEY_TO_FIELD: Record
= {
+ referrer: 'referrer_name',
+ country: 'country',
+ os: 'os',
+ browser: 'browser',
+ device: 'device',
+};
+
+const SESSION_FILTER_DEFINITIONS: FilterDefinition[] = [
+ { key: 'referrer', label: 'Referrer', type: 'select' },
+ { key: 'country', label: 'Country', type: 'select' },
+ { key: 'os', label: 'OS', type: 'select' },
+ { key: 'browser', label: 'Browser', type: 'select' },
+ { key: 'device', label: 'Device', type: 'select' },
+ { key: 'entryPage', label: 'Entry page', type: 'string' },
+ { key: 'exitPage', label: 'Exit page', type: 'string' },
+ { key: 'minPageViews', label: 'Min page views', type: 'number' },
+ { key: 'maxPageViews', label: 'Max page views', type: 'number' },
+ { key: 'minEvents', label: 'Min events', type: 'number' },
+ { key: 'maxEvents', label: 'Max events', type: 'number' },
+];
+
function SessionTableToolbar({ table }: { table: Table }) {
+ const { projectId } = useAppParams();
+ const trpc = useTRPC();
+ const queryClient = useQueryClient();
const { search, setSearch } = useSearchQueryState();
+ const { values, setValue, activeCount } = useSessionFilters();
+
+ const loadOptions = useCallback(
+ (key: string) => {
+ const field = SESSION_FILTER_KEY_TO_FIELD[key];
+ if (!field) {
+ return Promise.resolve([]);
+ }
+ return queryClient.fetchQuery(
+ trpc.session.distinctValues.queryOptions({
+ projectId,
+ field: field as
+ | 'referrer_name'
+ | 'country'
+ | 'os'
+ | 'browser'
+ | 'device',
+ })
+ );
+ },
+ [trpc, queryClient, projectId]
+ );
+
return (
-
+
+
+
+
+
+
);
diff --git a/apps/start/src/components/ui/filter-dropdown.tsx b/apps/start/src/components/ui/filter-dropdown.tsx
new file mode 100644
index 00000000..8edfbd21
--- /dev/null
+++ b/apps/start/src/components/ui/filter-dropdown.tsx
@@ -0,0 +1,328 @@
+import { AnimatePresence, motion } from 'framer-motion';
+import {
+ ArrowLeftIcon,
+ CheckIcon,
+ ChevronRightIcon,
+ Loader2Icon,
+ XIcon,
+} from 'lucide-react';
+import VirtualList from 'rc-virtual-list';
+import { useEffect, useState } from 'react';
+import { SerieIcon } from '@/components/report-chart/common/serie-icon';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from '@/components/ui/popover';
+import { Separator } from '@/components/ui/separator';
+import { cn } from '@/utils/cn';
+
+export type FilterType = 'select' | 'string' | 'number';
+
+export interface FilterDefinition {
+ key: string;
+ label: string;
+ type: FilterType;
+ /** For 'select' type: show SerieIcon next to options (default true) */
+ showIcon?: boolean;
+}
+
+interface FilterDropdownProps {
+ definitions: FilterDefinition[];
+ values: Record;
+ onChange: (key: string, value: string | number | null) => void;
+ loadOptions: (key: string) => Promise;
+ children: React.ReactNode;
+}
+
+export function FilterDropdown({
+ definitions,
+ values,
+ onChange,
+ loadOptions,
+ children,
+}: FilterDropdownProps) {
+ const [open, setOpen] = useState(false);
+ const [activeKey, setActiveKey] = useState(null);
+ const [direction, setDirection] = useState<'forward' | 'backward'>('forward');
+ const [search, setSearch] = useState('');
+ const [options, setOptions] = useState([]);
+ const [isLoading, setIsLoading] = useState(false);
+ const [inputValue, setInputValue] = useState('');
+
+ useEffect(() => {
+ if (!open) {
+ setActiveKey(null);
+ setSearch('');
+ setOptions([]);
+ setInputValue('');
+ }
+ }, [open]);
+
+ useEffect(() => {
+ if (!activeKey) {
+ return;
+ }
+ const def = definitions.find((d) => d.key === activeKey);
+ if (!def || def.type !== 'select') {
+ return;
+ }
+
+ setIsLoading(true);
+ loadOptions(activeKey)
+ .then((opts) => {
+ setOptions(opts);
+ setIsLoading(false);
+ })
+ .catch(() => setIsLoading(false));
+ }, [activeKey]);
+
+ const currentDef = activeKey
+ ? definitions.find((d) => d.key === activeKey)
+ : null;
+
+ const goToFilter = (key: string) => {
+ setDirection('forward');
+ setSearch('');
+ setOptions([]);
+ const current = values[key];
+ setInputValue(current != null ? String(current) : '');
+ setActiveKey(key);
+ };
+
+ const goBack = () => {
+ setDirection('backward');
+ setActiveKey(null);
+ setSearch('');
+ };
+
+ const applyValue = (key: string, value: string | number | null) => {
+ onChange(key, value);
+ goBack();
+ };
+
+ const renderIndex = () => (
+
+ {definitions.map((def) => {
+ const currentValue = values[def.key];
+ const isActive = currentValue != null && currentValue !== '';
+ return (
+
+ );
+ })}
+
+ );
+
+ const renderSelectFilter = () => {
+ const showIcon = currentDef?.showIcon !== false;
+ const filteredOptions = options.filter((opt) =>
+ opt.toLowerCase().includes(search.toLowerCase())
+ );
+ const currentValue = activeKey ? values[activeKey] : undefined;
+
+ return (
+
+
+
+
+
+
setSearch(e.target.value)}
+ placeholder="Search..."
+ value={search}
+ />
+
+
+ {isLoading ? (
+
+
+
+ ) : filteredOptions.length === 0 ? (
+
+ No options found
+
+ ) : (
+
item}
+ >
+ {(option) => (
+ applyValue(activeKey!, option)}
+ type="button"
+ >
+ {showIcon && }
+ {option || 'Direct'}
+
+
+ )}
+
+ )}
+
+ );
+ };
+
+ const renderStringFilter = () => (
+
+
+
+
+
+
{currentDef?.label}
+
+
+
+ setInputValue(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') {
+ applyValue(activeKey!, inputValue || null);
+ }
+ }}
+ placeholder={`Filter by ${currentDef?.label.toLowerCase()}...`}
+ value={inputValue}
+ />
+ applyValue(activeKey!, inputValue || null)}
+ size="sm"
+ >
+ Apply
+
+
+
+ );
+
+ const renderNumberFilter = () => (
+
+
+
+
+
+
{currentDef?.label}
+
+
+
+ setInputValue(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') {
+ applyValue(
+ activeKey!,
+ inputValue === '' ? null : Number(inputValue)
+ );
+ }
+ }}
+ placeholder="Enter value..."
+ type="number"
+ value={inputValue}
+ />
+
+ applyValue(
+ activeKey!,
+ inputValue === '' ? null : Number(inputValue)
+ )
+ }
+ size="sm"
+ >
+ Apply
+
+
+
+ );
+
+ const renderContent = () => {
+ if (!(activeKey && currentDef)) {
+ return renderIndex();
+ }
+ switch (currentDef.type) {
+ case 'select':
+ return renderSelectFilter();
+ case 'string':
+ return renderStringFilter();
+ case 'number':
+ return renderNumberFilter();
+ }
+ };
+
+ return (
+
+ {children}
+
+
+
+ {renderContent()}
+
+
+
+
+ );
+}
diff --git a/apps/start/src/hooks/use-session-filters.ts b/apps/start/src/hooks/use-session-filters.ts
new file mode 100644
index 00000000..8f105281
--- /dev/null
+++ b/apps/start/src/hooks/use-session-filters.ts
@@ -0,0 +1,229 @@
+import type { IChartEventFilter } from '@openpanel/validation';
+import { parseAsInteger, parseAsString, useQueryState } from 'nuqs';
+import { useCallback, useMemo } from 'react';
+
+const DEBOUNCE_MS = 500;
+const debounceOpts = {
+ clearOnDefault: true,
+ limitUrlUpdates: { method: 'debounce' as const, timeMs: DEBOUNCE_MS },
+};
+
+export function useSessionFilters() {
+ const [referrer, setReferrer] = useQueryState(
+ 'referrer',
+ parseAsString.withDefault('').withOptions(debounceOpts),
+ );
+ const [country, setCountry] = useQueryState(
+ 'country',
+ parseAsString.withDefault('').withOptions(debounceOpts),
+ );
+ const [os, setOs] = useQueryState(
+ 'os',
+ parseAsString.withDefault('').withOptions(debounceOpts),
+ );
+ const [browser, setBrowser] = useQueryState(
+ 'browser',
+ parseAsString.withDefault('').withOptions(debounceOpts),
+ );
+ const [device, setDevice] = useQueryState(
+ 'device',
+ parseAsString.withDefault('').withOptions(debounceOpts),
+ );
+ const [entryPage, setEntryPage] = useQueryState(
+ 'entryPage',
+ parseAsString.withDefault('').withOptions(debounceOpts),
+ );
+ const [exitPage, setExitPage] = useQueryState(
+ 'exitPage',
+ parseAsString.withDefault('').withOptions(debounceOpts),
+ );
+ const [minPageViews, setMinPageViews] = useQueryState(
+ 'minPageViews',
+ parseAsInteger,
+ );
+ const [maxPageViews, setMaxPageViews] = useQueryState(
+ 'maxPageViews',
+ parseAsInteger,
+ );
+ const [minEvents, setMinEvents] = useQueryState('minEvents', parseAsInteger);
+ const [maxEvents, setMaxEvents] = useQueryState('maxEvents', parseAsInteger);
+
+ const filters = useMemo(() => {
+ const result: IChartEventFilter[] = [];
+ if (referrer) {
+ result.push({ name: 'referrer_name', operator: 'is', value: [referrer] });
+ }
+ if (country) {
+ result.push({ name: 'country', operator: 'is', value: [country] });
+ }
+ if (os) {
+ result.push({ name: 'os', operator: 'is', value: [os] });
+ }
+ if (browser) {
+ result.push({ name: 'browser', operator: 'is', value: [browser] });
+ }
+ if (device) {
+ result.push({ name: 'device', operator: 'is', value: [device] });
+ }
+ if (entryPage) {
+ result.push({
+ name: 'entry_path',
+ operator: 'contains',
+ value: [entryPage],
+ });
+ }
+ if (exitPage) {
+ result.push({
+ name: 'exit_path',
+ operator: 'contains',
+ value: [exitPage],
+ });
+ }
+ return result;
+ }, [referrer, country, os, browser, device, entryPage, exitPage]);
+
+ const values = useMemo(
+ () => ({
+ referrer,
+ country,
+ os,
+ browser,
+ device,
+ entryPage,
+ exitPage,
+ minPageViews,
+ maxPageViews,
+ minEvents,
+ maxEvents,
+ }),
+ [
+ referrer,
+ country,
+ os,
+ browser,
+ device,
+ entryPage,
+ exitPage,
+ minPageViews,
+ maxPageViews,
+ minEvents,
+ maxEvents,
+ ],
+ );
+
+ const setValue = useCallback(
+ (key: string, value: string | number | null) => {
+ switch (key) {
+ case 'referrer':
+ setReferrer(String(value ?? ''));
+ break;
+ case 'country':
+ setCountry(String(value ?? ''));
+ break;
+ case 'os':
+ setOs(String(value ?? ''));
+ break;
+ case 'browser':
+ setBrowser(String(value ?? ''));
+ break;
+ case 'device':
+ setDevice(String(value ?? ''));
+ break;
+ case 'entryPage':
+ setEntryPage(String(value ?? ''));
+ break;
+ case 'exitPage':
+ setExitPage(String(value ?? ''));
+ break;
+ case 'minPageViews':
+ setMinPageViews(value != null ? Number(value) : null);
+ break;
+ case 'maxPageViews':
+ setMaxPageViews(value != null ? Number(value) : null);
+ break;
+ case 'minEvents':
+ setMinEvents(value != null ? Number(value) : null);
+ break;
+ case 'maxEvents':
+ setMaxEvents(value != null ? Number(value) : null);
+ break;
+ }
+ },
+ [
+ setReferrer,
+ setCountry,
+ setOs,
+ setBrowser,
+ setDevice,
+ setEntryPage,
+ setExitPage,
+ setMinPageViews,
+ setMaxPageViews,
+ setMinEvents,
+ setMaxEvents,
+ ],
+ );
+
+ const activeCount =
+ filters.length +
+ (minPageViews != null ? 1 : 0) +
+ (maxPageViews != null ? 1 : 0) +
+ (minEvents != null ? 1 : 0) +
+ (maxEvents != null ? 1 : 0);
+
+ const clearAll = useCallback(() => {
+ setReferrer('');
+ setCountry('');
+ setOs('');
+ setBrowser('');
+ setDevice('');
+ setEntryPage('');
+ setExitPage('');
+ setMinPageViews(null);
+ setMaxPageViews(null);
+ setMinEvents(null);
+ setMaxEvents(null);
+ }, [
+ setReferrer,
+ setCountry,
+ setOs,
+ setBrowser,
+ setDevice,
+ setEntryPage,
+ setExitPage,
+ setMinPageViews,
+ setMaxPageViews,
+ setMinEvents,
+ setMaxEvents,
+ ]);
+
+ return {
+ referrer,
+ setReferrer,
+ country,
+ setCountry,
+ os,
+ setOs,
+ browser,
+ setBrowser,
+ device,
+ setDevice,
+ entryPage,
+ setEntryPage,
+ exitPage,
+ setExitPage,
+ minPageViews,
+ setMinPageViews,
+ maxPageViews,
+ setMaxPageViews,
+ minEvents,
+ setMinEvents,
+ maxEvents,
+ setMaxEvents,
+ filters,
+ values,
+ setValue,
+ activeCount,
+ clearAll,
+ };
+}
diff --git a/apps/start/src/routes/_app.$organizationId.$projectId.sessions.tsx b/apps/start/src/routes/_app.$organizationId.$projectId.sessions.tsx
index 7fb9e506..a2f28203 100644
--- a/apps/start/src/routes/_app.$organizationId.$projectId.sessions.tsx
+++ b/apps/start/src/routes/_app.$organizationId.$projectId.sessions.tsx
@@ -1,19 +1,15 @@
+import { useInfiniteQuery } from '@tanstack/react-query';
+import { createFileRoute } from '@tanstack/react-router';
import { PageContainer } from '@/components/page-container';
import { PageHeader } from '@/components/page-header';
import { SessionsTable } from '@/components/sessions/table';
import { useSearchQueryState } from '@/hooks/use-search-query-state';
+import { useSessionFilters } from '@/hooks/use-session-filters';
import { useTRPC } from '@/integrations/trpc/react';
-import { PAGE_TITLES, createProjectTitle } from '@/utils/title';
-import {
- keepPreviousData,
- useInfiniteQuery,
- useQuery,
-} from '@tanstack/react-query';
-import { createFileRoute } from '@tanstack/react-router';
-import { parseAsString, parseAsStringEnum, useQueryState } from 'nuqs';
+import { createProjectTitle, PAGE_TITLES } from '@/utils/title';
export const Route = createFileRoute(
- '/_app/$organizationId/$projectId/sessions',
+ '/_app/$organizationId/$projectId/sessions'
)({
component: Component,
head: () => {
@@ -31,6 +27,8 @@ function Component() {
const { projectId } = Route.useParams();
const trpc = useTRPC();
const { debouncedSearch } = useSearchQueryState();
+ const { filters, minPageViews, maxPageViews, minEvents, maxEvents } =
+ useSessionFilters();
const query = useInfiniteQuery(
trpc.session.list.infiniteQueryOptions(
@@ -38,19 +36,24 @@ function Component() {
projectId,
take: 50,
search: debouncedSearch,
+ filters,
+ minPageViews,
+ maxPageViews,
+ minEvents,
+ maxEvents,
},
{
getNextPageParam: (lastPage) => lastPage.meta.next,
- },
- ),
+ }
+ )
);
return (
diff --git a/biome.json b/biome.json
index 9f718ec1..eb6da5b2 100644
--- a/biome.json
+++ b/biome.json
@@ -53,7 +53,9 @@
"noUnusedTemplateLiteral": "error",
"useNumberNamespace": "error",
"noInferrableTypes": "error",
- "noUselessElse": "error"
+ "noUselessElse": "error",
+ "noNestedTernary": "off",
+ "useDefaultSwitchClause": "off"
},
"correctness": {
"useExhaustiveDependencies": "off",
diff --git a/packages/db/src/services/session.service.ts b/packages/db/src/services/session.service.ts
index c7681a91..ed95f625 100644
--- a/packages/db/src/services/session.service.ts
+++ b/packages/db/src/services/session.service.ts
@@ -12,7 +12,6 @@ import {
import { clix } from '../clickhouse/query-builder';
import { createSqlBuilder } from '../sql-builder';
import { getEventFiltersWhereClause } from './chart.service';
-import { getOrganizationByProjectIdCached } from './organization.service';
import { getProfilesCached, type IServiceProfile } from './profile.service';
export interface IClickhouseSession {
@@ -106,7 +105,12 @@ export interface GetSessionListOptions {
startDate?: Date;
endDate?: Date;
search?: string;
- cursor?: Cursor | null;
+ cursor?: Date;
+ minPageViews?: number | null;
+ maxPageViews?: number | null;
+ minEvents?: number | null;
+ maxEvents?: number | null;
+ dateIntervalInDays?: number;
}
export function transformSession(session: IClickhouseSession): IServiceSession {
@@ -151,35 +155,51 @@ export function transformSession(session: IClickhouseSession): IServiceSession {
};
}
-interface PageInfo {
- next?: Cursor; // use last row
-}
+export async function getSessionList(options: GetSessionListOptions) {
+ const {
+ cursor,
+ take,
+ projectId,
+ profileId,
+ filters,
+ startDate,
+ endDate,
+ search,
+ minPageViews,
+ maxPageViews,
+ minEvents,
+ maxEvents,
+ dateIntervalInDays = 0.5,
+ } = options;
-interface Cursor {
- createdAt: string; // ISO 8601 with ms
- id: string;
-}
-
-export async function getSessionList({
- cursor,
- take,
- projectId,
- profileId,
- filters,
- startDate,
- endDate,
- search,
-}: GetSessionListOptions) {
const { sb, getSql } = createSqlBuilder();
sb.from = `${TABLE_NAMES.sessions} FINAL`;
sb.limit = take;
sb.where.projectId = `project_id = ${sqlstring.escape(projectId)}`;
- if (startDate && endDate) {
- sb.where.range = `created_at BETWEEN toDateTime('${formatClickhouseDate(startDate)}') AND toDateTime('${formatClickhouseDate(endDate)}')`;
+ const MAX_DATE_INTERVAL_IN_DAYS = 365;
+ // Cap the date interval to prevent infinity
+ const safeDateIntervalInDays = Math.min(
+ dateIntervalInDays,
+ MAX_DATE_INTERVAL_IN_DAYS
+ );
+
+ if (cursor instanceof Date) {
+ sb.where.cursorWindow = `created_at >= toDateTime64(${sqlstring.escape(formatClickhouseDate(cursor))}, 3) - INTERVAL ${safeDateIntervalInDays} DAY`;
+ sb.where.cursor = `created_at < ${sqlstring.escape(formatClickhouseDate(cursor))}`;
}
+ if (!(cursor || (startDate && endDate))) {
+ sb.where.cursorWindow = `created_at >= toDateTime64(${sqlstring.escape(formatClickhouseDate(new Date()))}, 3) - INTERVAL ${safeDateIntervalInDays} DAY`;
+ }
+
+ if (startDate && endDate) {
+ sb.where.created_at = `toDate(created_at) BETWEEN toDate('${formatClickhouseDate(startDate)}') AND toDate('${formatClickhouseDate(endDate)}')`;
+ }
+
+ sb.orderBy.created_at = 'created_at DESC';
+
if (profileId) {
sb.where.profileId = `profile_id = ${sqlstring.escape(profileId)}`;
}
@@ -190,27 +210,19 @@ export async function getSessionList({
if (filters?.length) {
Object.assign(sb.where, getEventFiltersWhereClause(filters));
}
-
- const organization = await getOrganizationByProjectIdCached(projectId);
- // This will speed up the query quite a lot for big organizations
- const dateIntervalInDays =
- organization?.subscriptionPeriodEventsLimit &&
- organization?.subscriptionPeriodEventsLimit > 1_000_000
- ? 2
- : 360;
-
- if (cursor) {
- const cAt = sqlstring.escape(cursor.createdAt);
- sb.where.cursor = `created_at < toDateTime64(${cAt}, 3)`;
- sb.where.cursorWindow = `created_at >= toDateTime64(${cAt}, 3) - INTERVAL ${dateIntervalInDays} DAY`;
- sb.orderBy.created_at = 'created_at DESC';
- } else {
- sb.orderBy.created_at = 'created_at DESC';
- sb.where.created_at = `created_at > now() - INTERVAL ${dateIntervalInDays} DAY`;
+ if (minPageViews != null) {
+ sb.where.minPageViews = `screen_view_count >= ${minPageViews}`;
+ }
+ if (maxPageViews != null) {
+ sb.where.maxPageViews = `screen_view_count <= ${maxPageViews}`;
+ }
+ if (minEvents != null) {
+ sb.where.minEvents = `event_count >= ${minEvents}`;
+ }
+ if (maxEvents != null) {
+ sb.where.maxEvents = `event_count <= ${maxEvents}`;
}
- // ==== Select columns (as you had) ====
- // sb.select.id = 'id'; sb.select.project_id = 'project_id'; ... etc.
const columns = [
'created_at',
'ended_at',
@@ -249,17 +261,17 @@ export async function getSessionList({
}
>(sql);
- // Compute cursors from page edges
- const last = data[take - 1];
-
- const meta: PageInfo = {
- next: last
- ? {
- createdAt: last.created_at,
- id: last.id,
- }
- : undefined,
- };
+ // If no results and we haven't reached the max window, retry with a larger interval
+ if (
+ data.length === 0 &&
+ sb.where.cursorWindow &&
+ safeDateIntervalInDays < MAX_DATE_INTERVAL_IN_DAYS
+ ) {
+ return getSessionList({
+ ...options,
+ dateIntervalInDays: dateIntervalInDays * 2,
+ });
+ }
// Profile hydration (unchanged)
const profileIds = data
@@ -283,6 +295,13 @@ export async function getSessionList({
},
}));
+ // Compute cursors from page edges
+ const last = items.at(-1);
+
+ const meta = {
+ next: last ? last.createdAt.toISOString() : undefined,
+ };
+
return { items, meta };
}
@@ -370,8 +389,41 @@ export async function getSessionReplayChunksFrom(
};
}
+export const SESSION_DISTINCT_FIELDS = [
+ 'referrer_name',
+ 'country',
+ 'os',
+ 'browser',
+ 'device',
+] as const;
+
+export type SessionDistinctField = (typeof SESSION_DISTINCT_FIELDS)[number];
+
+export async function getSessionDistinctValues(
+ projectId: string,
+ field: SessionDistinctField,
+ limit = 200
+): Promise {
+ const sql = `
+ SELECT ${field} AS value, count() AS cnt
+ FROM ${TABLE_NAMES.sessions}
+ WHERE project_id = ${sqlstring.escape(projectId)}
+ AND ${field} != ''
+ AND sign = 1
+ AND created_at > now() - INTERVAL 90 DAY
+ GROUP BY value
+ ORDER BY cnt DESC
+ LIMIT ${limit}
+ `;
+ const results = await chQuery<{ value: string }>(sql);
+ return results.map((r) => r.value).filter(Boolean);
+}
+
class SessionService {
- constructor(private client: typeof ch) {}
+ private readonly client: typeof ch;
+ constructor(client: typeof ch) {
+ this.client = client;
+ }
async byId(sessionId: string, projectId: string) {
const [sessionRows, hasReplayRows] = await Promise.all([
diff --git a/packages/trpc/src/routers/session.ts b/packages/trpc/src/routers/session.ts
index e924e98a..9c60a979 100644
--- a/packages/trpc/src/routers/session.ts
+++ b/packages/trpc/src/routers/session.ts
@@ -1,6 +1,8 @@
import {
+ getSessionDistinctValues,
getSessionList,
getSessionReplayChunksFrom,
+ SESSION_DISTINCT_FIELDS,
sessionService,
} from '@openpanel/db';
import { zChartEventFilter } from '@openpanel/validation';
@@ -42,20 +44,28 @@ export const sessionRouter = createTRPCRouter({
endDate: z.date().optional(),
search: z.string().optional(),
take: z.number().default(50),
+ minPageViews: z.number().nullish(),
+ maxPageViews: z.number().nullish(),
+ minEvents: z.number().nullish(),
+ maxEvents: z.number().nullish(),
})
)
- .query(async ({ input }) => {
- const cursor = input.cursor ? decodeCursor(input.cursor) : null;
- const data = await getSessionList({
+ .query(({ input }) => {
+ return getSessionList({
...input,
- cursor,
+ cursor: input.cursor ? new Date(input.cursor) : undefined,
});
- return {
- data: data.items,
- meta: {
- next: data.meta.next ? encodeCursor(data.meta.next) : undefined,
- },
- };
+ }),
+
+ distinctValues: protectedProcedure
+ .input(
+ z.object({
+ projectId: z.string(),
+ field: z.enum(SESSION_DISTINCT_FIELDS),
+ })
+ )
+ .query(({ input }) => {
+ return getSessionDistinctValues(input.projectId, input.field);
}),
byId: protectedProcedure