fix: add filters for sessions table
This commit is contained in:
@@ -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(
|
||||
<>
|
||||
<ProfileTableToolbar table={table} />
|
||||
<DataTable
|
||||
table={table}
|
||||
loading={isLoading}
|
||||
onRowClick={handleRowClick}
|
||||
empty={{
|
||||
title: 'No profiles',
|
||||
description: "Looks like you haven't identified any profiles yet.",
|
||||
}}
|
||||
loading={isLoading}
|
||||
onRowClick={handleRowClick}
|
||||
table={table}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
},
|
||||
arePropsEqual(['query.isLoading', 'query.data', 'type', 'pageSize']),
|
||||
arePropsEqual(['query.isLoading', 'query.data', 'type', 'pageSize'])
|
||||
);
|
||||
|
||||
function ProfileTableToolbar({ table }: { table: Table<IServiceProfile> }) {
|
||||
@@ -113,9 +113,9 @@ function ProfileTableToolbar({ table }: { table: Table<IServiceProfile> }) {
|
||||
return (
|
||||
<DataTableToolbarContainer>
|
||||
<AnimatedSearchInput
|
||||
onChange={setSearch}
|
||||
placeholder="Search profiles"
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
/>
|
||||
<DataTableViewOptions table={table} />
|
||||
</DataTableToolbarContainer>
|
||||
|
||||
@@ -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<string, boolean>,
|
||||
{} as Record<string, boolean>
|
||||
);
|
||||
}, [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 (
|
||||
<div
|
||||
data-index={virtualRow.index}
|
||||
ref={virtualRow.measureElement}
|
||||
className={cn(
|
||||
'absolute top-0 left-0 w-full border-b hover:bg-muted/50 transition-colors group/row',
|
||||
onRowClick && 'cursor-pointer',
|
||||
'group/row absolute top-0 left-0 w-full border-b transition-colors hover:bg-muted/50',
|
||||
onRowClick && 'cursor-pointer'
|
||||
)}
|
||||
data-index={virtualRow.index}
|
||||
onClick={
|
||||
onRowClick
|
||||
? (e) => {
|
||||
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 (
|
||||
<div
|
||||
className="flex items-center whitespace-nowrap p-2 px-4 align-middle"
|
||||
key={cell.id}
|
||||
className="flex items-center p-2 px-4 align-middle whitespace-nowrap"
|
||||
style={{
|
||||
width,
|
||||
overflow: 'hidden',
|
||||
@@ -161,7 +167,7 @@ const VirtualRow = memo(
|
||||
prevProps.headerColumnsHash === nextProps.headerColumnsHash &&
|
||||
prevProps.onRowClick === nextProps.onRowClick
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const VirtualizedSessionsTable = ({
|
||||
@@ -188,12 +194,12 @@ const VirtualizedSessionsTable = ({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-full overflow-x-auto rounded-md border bg-card"
|
||||
ref={parentRef}
|
||||
className="w-full overflow-x-auto border rounded-md bg-card"
|
||||
>
|
||||
{/* Table Header */}
|
||||
<div
|
||||
className="sticky top-0 z-10 bg-card border-b"
|
||||
className="sticky top-0 z-10 border-b bg-card"
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: headerColumns
|
||||
@@ -207,8 +213,8 @@ const VirtualizedSessionsTable = ({
|
||||
const width = `${column.getSize()}px`;
|
||||
return (
|
||||
<div
|
||||
className="flex h-10 items-center whitespace-nowrap px-4 text-left font-semibold text-[10px] text-foreground uppercase"
|
||||
key={column.id}
|
||||
className="flex items-center h-10 px-4 text-left text-[10px] uppercase text-foreground font-semibold whitespace-nowrap"
|
||||
style={{
|
||||
width,
|
||||
}}
|
||||
@@ -221,8 +227,8 @@ const VirtualizedSessionsTable = ({
|
||||
|
||||
{!isLoading && data.length === 0 && (
|
||||
<FullPageEmptyState
|
||||
title="No sessions found"
|
||||
description="Looks like you haven't inserted any events yet."
|
||||
title="No sessions found"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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 (
|
||||
<VirtualRow
|
||||
headerColumns={headerColumns}
|
||||
headerColumnsHash={headerColumnsHash}
|
||||
isLoading={isLoading}
|
||||
key={row.id}
|
||||
onRowClick={onRowClick}
|
||||
row={row}
|
||||
scrollMargin={rowVirtualizer.options.scrollMargin}
|
||||
virtualRow={{
|
||||
...virtualRow,
|
||||
measureElement: rowVirtualizer.measureElement,
|
||||
}}
|
||||
headerColumns={headerColumns}
|
||||
headerColumnsHash={headerColumnsHash}
|
||||
scrollMargin={rowVirtualizer.options.scrollMargin}
|
||||
isLoading={isLoading}
|
||||
onRowClick={onRowClick}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -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) => {
|
||||
<>
|
||||
<SessionTableToolbar table={table} />
|
||||
<VirtualizedSessionsTable
|
||||
table={table}
|
||||
data={data}
|
||||
isLoading={isLoading}
|
||||
onRowClick={handleRowClick}
|
||||
table={table}
|
||||
/>
|
||||
<div className="w-full h-10 center-center pt-4" ref={inViewportRef}>
|
||||
<div className="center-center h-10 w-full pt-4" ref={inViewportRef}>
|
||||
<div
|
||||
className={cn(
|
||||
'size-8 bg-background rounded-full center-center border opacity-0 transition-opacity',
|
||||
query.isFetchingNextPage && 'opacity-100',
|
||||
'center-center size-8 rounded-full border bg-background opacity-0 transition-opacity',
|
||||
query.isFetchingNextPage && 'opacity-100'
|
||||
)}
|
||||
>
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
@@ -350,15 +357,88 @@ export const SessionsTable = ({ query }: Props) => {
|
||||
);
|
||||
};
|
||||
|
||||
const SESSION_FILTER_KEY_TO_FIELD: Record<string, string> = {
|
||||
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<IServiceSession> }) {
|
||||
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 (
|
||||
<DataTableToolbarContainer>
|
||||
<AnimatedSearchInput
|
||||
placeholder="Search sessions by path, referrer..."
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
/>
|
||||
<div className="flex flex-1 flex-wrap items-center gap-2">
|
||||
<AnimatedSearchInput
|
||||
onChange={setSearch}
|
||||
placeholder="Search sessions by path, referrer..."
|
||||
value={search}
|
||||
/>
|
||||
<FilterDropdown
|
||||
definitions={SESSION_FILTER_DEFINITIONS}
|
||||
loadOptions={loadOptions}
|
||||
onChange={setValue}
|
||||
values={values}
|
||||
>
|
||||
<Button
|
||||
className={cn(
|
||||
'border-dashed',
|
||||
activeCount > 0 && 'border-primary border-solid'
|
||||
)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
<SlidersHorizontalIcon className="mr-2 size-4" />
|
||||
Filters
|
||||
{activeCount > 0 && (
|
||||
<Badge className="ml-2 rounded-full px-1.5 py-0 text-xs">
|
||||
{activeCount}
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
</FilterDropdown>
|
||||
</div>
|
||||
<DataTableViewOptions table={table} />
|
||||
</DataTableToolbarContainer>
|
||||
);
|
||||
|
||||
328
apps/start/src/components/ui/filter-dropdown.tsx
Normal file
328
apps/start/src/components/ui/filter-dropdown.tsx
Normal file
@@ -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<string, string | number | null | undefined>;
|
||||
onChange: (key: string, value: string | number | null) => void;
|
||||
loadOptions: (key: string) => Promise<string[]>;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function FilterDropdown({
|
||||
definitions,
|
||||
values,
|
||||
onChange,
|
||||
loadOptions,
|
||||
children,
|
||||
}: FilterDropdownProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [activeKey, setActiveKey] = useState<string | null>(null);
|
||||
const [direction, setDirection] = useState<'forward' | 'backward'>('forward');
|
||||
const [search, setSearch] = useState('');
|
||||
const [options, setOptions] = useState<string[]>([]);
|
||||
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 = () => (
|
||||
<div className="min-w-52">
|
||||
{definitions.map((def) => {
|
||||
const currentValue = values[def.key];
|
||||
const isActive = currentValue != null && currentValue !== '';
|
||||
return (
|
||||
<button
|
||||
className="flex w-full cursor-pointer items-center justify-between gap-2 rounded-md px-3 py-2 hover:bg-accent"
|
||||
key={def.key}
|
||||
onClick={() => goToFilter(def.key)}
|
||||
type="button"
|
||||
>
|
||||
<span className="font-medium text-sm">{def.label}</span>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{isActive && (
|
||||
<>
|
||||
<span className="max-w-24 truncate text-muted-foreground text-xs">
|
||||
{String(currentValue)}
|
||||
</span>
|
||||
<button
|
||||
className="rounded-sm p-0.5 text-muted-foreground hover:text-foreground"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onChange(def.key, null);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<XIcon className="size-3" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<ChevronRightIcon className="size-4 text-muted-foreground" />
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderSelectFilter = () => {
|
||||
const showIcon = currentDef?.showIcon !== false;
|
||||
const filteredOptions = options.filter((opt) =>
|
||||
opt.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
const currentValue = activeKey ? values[activeKey] : undefined;
|
||||
|
||||
return (
|
||||
<div className="min-w-52">
|
||||
<div className="flex items-center gap-1 p-1">
|
||||
<Button
|
||||
className="size-7 shrink-0"
|
||||
onClick={goBack}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<ArrowLeftIcon className="size-4" />
|
||||
</Button>
|
||||
<Input
|
||||
autoFocus
|
||||
className="h-7 text-sm"
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search..."
|
||||
value={search}
|
||||
/>
|
||||
</div>
|
||||
<Separator />
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center p-6 text-muted-foreground">
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
</div>
|
||||
) : filteredOptions.length === 0 ? (
|
||||
<div className="p-4 text-center text-muted-foreground text-sm">
|
||||
No options found
|
||||
</div>
|
||||
) : (
|
||||
<VirtualList
|
||||
data={filteredOptions}
|
||||
height={Math.min(filteredOptions.length * 36, 250)}
|
||||
itemHeight={36}
|
||||
itemKey={(item) => item}
|
||||
>
|
||||
{(option) => (
|
||||
<button
|
||||
className="flex cursor-pointer items-center gap-2 rounded-md px-3 py-2 hover:bg-accent"
|
||||
onClick={() => applyValue(activeKey!, option)}
|
||||
type="button"
|
||||
>
|
||||
{showIcon && <SerieIcon name={option} />}
|
||||
<span className="truncate text-sm">{option || 'Direct'}</span>
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
'ml-auto size-4 shrink-0',
|
||||
currentValue === option ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</VirtualList>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderStringFilter = () => (
|
||||
<div className="min-w-52">
|
||||
<div className="flex items-center gap-1 p-1">
|
||||
<Button
|
||||
className="size-7 shrink-0"
|
||||
onClick={goBack}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<ArrowLeftIcon className="size-4" />
|
||||
</Button>
|
||||
<span className="px-1 font-medium text-sm">{currentDef?.label}</span>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex flex-col gap-2 p-2">
|
||||
<Input
|
||||
autoFocus
|
||||
className="h-8 text-sm"
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
applyValue(activeKey!, inputValue || null);
|
||||
}
|
||||
}}
|
||||
placeholder={`Filter by ${currentDef?.label.toLowerCase()}...`}
|
||||
value={inputValue}
|
||||
/>
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => applyValue(activeKey!, inputValue || null)}
|
||||
size="sm"
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderNumberFilter = () => (
|
||||
<div className="min-w-52">
|
||||
<div className="flex items-center gap-1 p-1">
|
||||
<Button
|
||||
className="size-7 shrink-0"
|
||||
onClick={goBack}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<ArrowLeftIcon className="size-4" />
|
||||
</Button>
|
||||
<span className="px-1 font-medium text-sm">{currentDef?.label}</span>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex flex-col gap-2 p-2">
|
||||
<Input
|
||||
autoFocus
|
||||
className="h-8 text-sm"
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
applyValue(
|
||||
activeKey!,
|
||||
inputValue === '' ? null : Number(inputValue)
|
||||
);
|
||||
}
|
||||
}}
|
||||
placeholder="Enter value..."
|
||||
type="number"
|
||||
value={inputValue}
|
||||
/>
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() =>
|
||||
applyValue(
|
||||
activeKey!,
|
||||
inputValue === '' ? null : Number(inputValue)
|
||||
)
|
||||
}
|
||||
size="sm"
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderContent = () => {
|
||||
if (!(activeKey && currentDef)) {
|
||||
return renderIndex();
|
||||
}
|
||||
switch (currentDef.type) {
|
||||
case 'select':
|
||||
return renderSelectFilter();
|
||||
case 'string':
|
||||
return renderStringFilter();
|
||||
case 'number':
|
||||
return renderNumberFilter();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover onOpenChange={setOpen} open={open}>
|
||||
<PopoverTrigger asChild>{children}</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-auto p-1">
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
<motion.div
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: direction === 'forward' ? -20 : 20 }}
|
||||
initial={{ opacity: 0, x: direction === 'forward' ? 20 : -20 }}
|
||||
key={activeKey ?? 'index'}
|
||||
transition={{ duration: 0.1 }}
|
||||
>
|
||||
{renderContent()}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
229
apps/start/src/hooks/use-session-filters.ts
Normal file
229
apps/start/src/hooks/use-session-filters.ts
Normal file
@@ -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<IChartEventFilter[]>(() => {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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 (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Sessions"
|
||||
description="Access all your sessions here"
|
||||
className="mb-8"
|
||||
description="Access all your sessions here"
|
||||
title="Sessions"
|
||||
/>
|
||||
<SessionsTable query={query} />
|
||||
</PageContainer>
|
||||
|
||||
@@ -53,7 +53,9 @@
|
||||
"noUnusedTemplateLiteral": "error",
|
||||
"useNumberNamespace": "error",
|
||||
"noInferrableTypes": "error",
|
||||
"noUselessElse": "error"
|
||||
"noUselessElse": "error",
|
||||
"noNestedTernary": "off",
|
||||
"useDefaultSwitchClause": "off"
|
||||
},
|
||||
"correctness": {
|
||||
"useExhaustiveDependencies": "off",
|
||||
|
||||
@@ -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<string[]> {
|
||||
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([
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user