diff --git a/apps/start/src/components/profiles/table/index.tsx b/apps/start/src/components/profiles/table/index.tsx index 37d62b99..9c534b31 100644 --- a/apps/start/src/components/profiles/table/index.tsx +++ b/apps/start/src/components/profiles/table/index.tsx @@ -1,11 +1,15 @@ +import type { IServiceProfile } from '@openpanel/db'; import type { UseQueryResult } from '@tanstack/react-query'; - -import { useDataTableColumnVisibility } from '@/components/ui/data-table/data-table-hooks'; -import type { RouterOutputs } from '@/trpc/client'; +import { useNavigate } from '@tanstack/react-router'; +import type { PaginationState, Table, Updater } from '@tanstack/react-table'; +import { getCoreRowModel, useReactTable } from '@tanstack/react-table'; +import { memo, useCallback } from 'react'; import { useColumns } from './columns'; - import { DataTable } from '@/components/ui/data-table/data-table'; -import { useDataTablePagination } from '@/components/ui/data-table/data-table-hooks'; +import { + useDataTableColumnVisibility, + useDataTablePagination, +} from '@/components/ui/data-table/data-table-hooks'; import { AnimatedSearchInput, DataTableToolbarContainer, @@ -13,12 +17,8 @@ import { import { DataTableViewOptions } from '@/components/ui/data-table/data-table-view-options'; import { useAppParams } from '@/hooks/use-app-params'; import { useSearchQueryState } from '@/hooks/use-search-query-state'; +import type { RouterOutputs } from '@/trpc/client'; import { arePropsEqual } from '@/utils/are-props-equal'; -import type { IServiceProfile } from '@openpanel/db'; -import { useNavigate } from '@tanstack/react-router'; -import type { PaginationState, Table, Updater } from '@tanstack/react-table'; -import { getCoreRowModel, useReactTable } from '@tanstack/react-table'; -import { memo, useCallback } from 'react'; const PAGE_SIZE = 50; @@ -40,7 +40,7 @@ export const ProfilesTable = memo( const handleRowClick = useCallback( (row: any) => { navigate({ - to: '/$organizationId/$projectId/profiles/$profileId' as any, + to: '/$organizationId/$projectId/profiles/$profileId', params: { organizationId, projectId, @@ -48,7 +48,7 @@ export const ProfilesTable = memo( }, }); }, - [navigate, organizationId, projectId], + [navigate, organizationId, projectId] ); const { setPage, state: pagination } = useDataTablePagination(pageSize); @@ -68,7 +68,7 @@ export const ProfilesTable = memo( columns, rowCount: data?.meta.count, pageCount: Math.ceil( - (data?.meta.count || 0) / (pagination.pageSize || 1), + (data?.meta.count || 0) / (pagination.pageSize || 1) ), filterFns: { isWithinRange: () => true, @@ -94,18 +94,18 @@ export const ProfilesTable = memo( <> ); }, - arePropsEqual(['query.isLoading', 'query.data', 'type', 'pageSize']), + arePropsEqual(['query.isLoading', 'query.data', 'type', 'pageSize']) ); function ProfileTableToolbar({ table }: { table: Table }) { @@ -113,9 +113,9 @@ function ProfileTableToolbar({ table }: { table: Table }) { return ( diff --git a/apps/start/src/components/sessions/table/index.tsx b/apps/start/src/components/sessions/table/index.tsx index 1217785b..95bd8272 100644 --- a/apps/start/src/components/sessions/table/index.tsx +++ b/apps/start/src/components/sessions/table/index.tsx @@ -1,9 +1,7 @@ import type { UseInfiniteQueryResult } from '@tanstack/react-query'; - -import { useDataTableColumnVisibility } from '@/components/ui/data-table/data-table-hooks'; -import type { RouterInputs, RouterOutputs } from '@/trpc/client'; import { useLocalStorage } from 'usehooks-ts'; import { useColumns } from './columns'; +import type { RouterInputs, RouterOutputs } from '@/trpc/client'; // Custom hook for persistent column visibility const usePersistentColumnVisibility = (columns: any[]) => { @@ -21,7 +19,7 @@ const usePersistentColumnVisibility = (columns: any[]) => { } return acc; }, - {} as Record, + {} as Record ); }, [columns, savedVisibility]); @@ -37,27 +35,33 @@ const usePersistentColumnVisibility = (columns: any[]) => { }; }; -import { FullPageEmptyState } from '@/components/full-page-empty-state'; -import { Skeleton } from '@/components/skeleton'; -import { - AnimatedSearchInput, - DataTableToolbarContainer, -} from '@/components/ui/data-table/data-table-toolbar'; -import { DataTableViewOptions } from '@/components/ui/data-table/data-table-view-options'; -import { useAppParams } from '@/hooks/use-app-params'; -import { useSearchQueryState } from '@/hooks/use-search-query-state'; -import { arePropsEqual } from '@/utils/are-props-equal'; -import { cn } from '@/utils/cn'; import type { IServiceSession } from '@openpanel/db'; +import { useQueryClient } from '@tanstack/react-query'; import { useNavigate } from '@tanstack/react-router'; import type { Table } from '@tanstack/react-table'; import { getCoreRowModel, useReactTable } from '@tanstack/react-table'; import { useWindowVirtualizer } from '@tanstack/react-virtual'; import type { TRPCInfiniteData } from '@trpc/tanstack-react-query'; -import { Loader2Icon } from 'lucide-react'; +import { Loader2Icon, SlidersHorizontalIcon } from 'lucide-react'; import { last } from 'ramda'; -import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { memo, useCallback, useEffect, useMemo, useRef } from 'react'; import { useInViewport } from 'react-in-viewport'; +import { FullPageEmptyState } from '@/components/full-page-empty-state'; +import { Skeleton } from '@/components/skeleton'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { + AnimatedSearchInput, + DataTableToolbarContainer, +} from '@/components/ui/data-table/data-table-toolbar'; +import { DataTableViewOptions } from '@/components/ui/data-table/data-table-view-options'; +import type { FilterDefinition } from '@/components/ui/filter-dropdown'; +import { FilterDropdown } from '@/components/ui/filter-dropdown'; +import { useAppParams } from '@/hooks/use-app-params'; +import { useSearchQueryState } from '@/hooks/use-search-query-state'; +import { useSessionFilters } from '@/hooks/use-session-filters'; +import { useTRPC } from '@/integrations/trpc/react'; +import { cn } from '@/utils/cn'; type Props = { query: UseInfiniteQueryResult< @@ -99,20 +103,22 @@ const VirtualRow = memo( }: VirtualRowProps) { return (
{ - 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) => ( + + )} + + )} +
+ ); + }; + + 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} + /> + +
+
+ ); + + 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} + /> + +
+
+ ); + + 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