diff --git a/apps/start/src/components/clients/table/index.tsx b/apps/start/src/components/clients/table/index.tsx index a53f8eb3..5ef59cf7 100644 --- a/apps/start/src/components/clients/table/index.tsx +++ b/apps/start/src/components/clients/table/index.tsx @@ -18,6 +18,7 @@ export const ClientsTable = ({ query }: Props) => { const { data, isLoading } = query; const { table } = useTable({ + name: 'clients', columns, data: data ?? [], loading: isLoading, diff --git a/apps/start/src/components/events/table/columns.tsx b/apps/start/src/components/events/table/columns.tsx index ec7454f5..a1725ae4 100644 --- a/apps/start/src/components/events/table/columns.tsx +++ b/apps/start/src/components/events/table/columns.tsx @@ -3,16 +3,34 @@ import { ProjectLink } from '@/components/links'; import { SerieIcon } from '@/components/report-chart/common/serie-icon'; import { useNumber } from '@/hooks/use-numer-formatter'; import { pushModal } from '@/modals'; -import { formatDateTime, formatTime } from '@/utils/date'; +import { formatDateTime, formatTimeAgoOrDateTime } from '@/utils/date'; import { getProfileName } from '@/utils/getters'; import type { ColumnDef } from '@tanstack/react-table'; -import { isToday } from 'date-fns'; +import { KeyValueGrid } from '@/components/ui/key-value-grid'; import type { IServiceEvent } from '@openpanel/db'; export function useColumns() { const number = useNumber(); const columns: ColumnDef[] = [ + { + accessorKey: 'createdAt', + header: 'Created at', + size: 140, + cell: ({ row }) => { + const session = row.original; + return ( +
+
+ {formatDateTime(session.createdAt)} +
+
+ {formatTimeAgoOrDateTime(session.createdAt)} +
+
+ ); + }, + }, { size: 300, accessorKey: 'name', @@ -85,17 +103,6 @@ export function useColumns() { ); }, }, - { - accessorKey: 'createdAt', - header: 'Created at', - size: 170, - cell({ row }) { - const date = row.original.createdAt; - return ( -
{isToday(date) ? formatTime(date) : formatDateTime(date)}
- ); - }, - }, { accessorKey: 'profileId', header: 'Profile', @@ -141,11 +148,17 @@ export function useColumns() { accessorKey: 'sessionId', header: 'Session ID', size: 320, + meta: { + hidden: true, + }, }, { accessorKey: 'deviceId', header: 'Device ID', size: 320, + meta: { + hidden: true, + }, }, { accessorKey: 'country', @@ -193,6 +206,9 @@ export function useColumns() { accessorKey: 'properties', header: 'Properties', size: 400, + meta: { + hidden: true, + }, cell({ row }) { const { properties } = row.original; const filteredProperties = Object.fromEntries( @@ -201,19 +217,23 @@ export function useColumns() { ), ); const items = Object.entries(filteredProperties); - return ( -
- {items.slice(0, 4).map(([key, value]) => ( -
- {key} - {String(value)} -
- ))} - {items.length > 5 && ( - {items.length - 5} more - )} -
- ); + const limit = 1; + const data = items.slice(0, limit).map(([key, value]) => ({ + name: key, + value: value, + })); + if (items.length > limit) { + data.push({ + name: '', + value: `${items.length - limit} more item${items.length - limit === 1 ? '' : 's'}`, + }); + } + + if (data.length === 0) { + return null; + } + + return ; }, }, ]; diff --git a/apps/start/src/components/events/table/index.tsx b/apps/start/src/components/events/table/index.tsx index bb0b88ae..ac4e1742 100644 --- a/apps/start/src/components/events/table/index.tsx +++ b/apps/start/src/components/events/table/index.tsx @@ -1,30 +1,22 @@ -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, -} from '@/components/ui/command'; -import { - Popover, - PopoverContent, - PopoverPortal, - PopoverTrigger, -} from '@/components/ui/popover'; -import { Check, ChevronsUpDown, Settings2Icon } from 'lucide-react'; - import { FullPageEmptyState } from '@/components/full-page-empty-state'; import { OverviewFiltersButtons } from '@/components/overview/filters/overview-filters-buttons'; import { OverviewFiltersDrawer } from '@/components/overview/filters/overview-filters-drawer'; +import { Skeleton } from '@/components/skeleton'; import { Button } from '@/components/ui/button'; +import { useDataTableColumnVisibility } from '@/components/ui/data-table/data-table-hooks'; import { 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 { pushModal } from '@/modals'; import type { RouterInputs, RouterOutputs } from '@/trpc/client'; import { arePropsEqual } from '@/utils/are-props-equal'; import { cn } from '@/utils/cn'; +import type { IServiceEvent } from '@openpanel/db'; import type { UseInfiniteQueryResult } from '@tanstack/react-query'; +import type { Table } from '@tanstack/react-table'; +import { getCoreRowModel, useReactTable } from '@tanstack/react-table'; +import { Updater } from '@tanstack/react-table'; +import { ColumnOrderState } from '@tanstack/react-table'; import { useWindowVirtualizer } from '@tanstack/react-virtual'; import type { TRPCInfiniteData } from '@trpc/tanstack-react-query'; import { format } from 'date-fns'; @@ -32,20 +24,11 @@ import throttle from 'lodash.throttle'; import { CalendarIcon, Loader2Icon } from 'lucide-react'; import { parseAsIsoDateTime, useQueryState } from 'nuqs'; import { last } from 'ramda'; -import { memo, useEffect, useRef, useState } from 'react'; +import { memo, useEffect, useMemo, useRef, useState } from 'react'; import { useInViewport } from 'react-in-viewport'; import { useLocalStorage } from 'usehooks-ts'; import EventListener from '../event-listener'; -import { EventItem, EventItemSkeleton } from './item'; - -export const useEventsViewOptions = () => { - return useLocalStorage>( - '@op:events-table-view-options', - { - properties: false, - }, - ); -}; +import { useColumns } from './columns'; type Props = { query: UseInfiniteQueryResult< @@ -57,137 +40,263 @@ type Props = { >; }; -export const EventsTable = memo( - ({ query }: Props) => { - const [viewOptions] = useEventsViewOptions(); - const { isLoading } = query; - const parentRef = useRef(null); - const [scrollMargin, setScrollMargin] = useState(0); - const inViewportRef = useRef(null); - const { inViewport, enterCount } = useInViewport(inViewportRef, undefined, { - disconnectOnLeave: true, - }); +const LOADING_DATA = [{}, {}, {}, {}, {}, {}, {}, {}, {}] as IServiceEvent[]; +const ROW_HEIGHT = 40; - const data = query.data?.pages?.flatMap((p) => p.data) ?? []; +interface VirtualizedEventsTableProps { + table: Table; + data: IServiceEvent[]; + isLoading: boolean; +} - const virtualizer = useWindowVirtualizer({ - count: data.length, - estimateSize: () => 55, - scrollMargin, - overscan: 10, - }); - - useEffect(() => { - const updateScrollMargin = throttle(() => { - if (parentRef.current) { - setScrollMargin( - parentRef.current.getBoundingClientRect().top + window.scrollY, - ); - } - }, 500); - - // Initial calculation - updateScrollMargin(); - - // Listen for resize events - window.addEventListener('resize', updateScrollMargin); - - return () => { - window.removeEventListener('resize', updateScrollMargin); - }; - }, []); - - useEffect(() => { - virtualizer.measure(); - }, [viewOptions, virtualizer]); - - const hasNextPage = last(query.data?.pages ?? [])?.meta.next; - - useEffect(() => { - if ( - hasNextPage && - data.length > 0 && - inViewport && - enterCount > 0 && - query.isFetchingNextPage === false - ) { - query.fetchNextPage(); - } - }, [inViewport, enterCount, hasNextPage]); - - const visibleItems = virtualizer.getVirtualItems(); +interface VirtualRowProps { + row: any; + virtualRow: any; + headerColumns: any[]; + scrollMargin: number; + isLoading: boolean; + headerColumnsHash: string; +} +const VirtualRow = memo( + function VirtualRow({ + row, + virtualRow, + headerColumns, + scrollMargin, + isLoading, + }: VirtualRowProps) { return ( - <> - -
- {isLoading && ( -
- - - - - - +
`${col.getSize()}px`) + .join(' '), + minWidth: 'fit-content', + minHeight: ROW_HEIGHT, + }} + > + {row.getVisibleCells().map((cell: any) => { + const width = `${cell.column.getSize()}px`; + return ( +
+ {isLoading ? ( + + ) : cell.column.columnDef.cell ? ( + typeof cell.column.columnDef.cell === 'function' ? ( + cell.column.columnDef.cell(cell.getContext()) + ) : ( + cell.column.columnDef.cell + ) + ) : ( + (cell.getValue() as React.ReactNode) + )}
- )} - {!isLoading && data.length === 0 && ( - - )} -
- {visibleItems.map((virtualRow) => ( -
- -
- ))} -
-
-
-
- -
-
- + ); + })} +
+ ); + }, + (prevProps, nextProps) => { + return ( + prevProps.row.id === nextProps.row.id && + prevProps.virtualRow.index === nextProps.virtualRow.index && + prevProps.virtualRow.start === nextProps.virtualRow.start && + prevProps.virtualRow.size === nextProps.virtualRow.size && + prevProps.isLoading === nextProps.isLoading && + prevProps.headerColumnsHash === nextProps.headerColumnsHash ); }, - arePropsEqual(['query.isLoading', 'query.data', 'query.isFetchingNextPage']), ); +const VirtualizedEventsTable = ({ + table, + data, + isLoading, +}: VirtualizedEventsTableProps) => { + const parentRef = useRef(null); + + const headerColumns = table.getAllLeafColumns().filter((col) => { + return table.getState().columnVisibility[col.id] !== false; + }); + + const rowVirtualizer = useWindowVirtualizer({ + count: data.length, + estimateSize: () => ROW_HEIGHT, + overscan: 10, + scrollMargin: parentRef.current?.offsetTop ?? 0, + }); + + useEffect(() => { + rowVirtualizer.measure(); + }, [headerColumns.length]); + + const virtualRows = rowVirtualizer.getVirtualItems(); + const headerColumnsHash = headerColumns.map((col) => col.id).join(','); + return ( +
+ {/* Table Header */} +
`${col.getSize()}px`) + .join(' '), + minWidth: 'fit-content', + }} + > + {headerColumns.map((column) => { + const header = column.columnDef.header; + const width = `${column.getSize()}px`; + return ( +
+ {typeof header === 'function' ? header({} as any) : header} +
+ ); + })} +
+ + {!isLoading && data.length === 0 && ( + + )} + + {/* Table Body */} +
+ {virtualRows.map((virtualRow) => { + const row = table.getRowModel().rows[virtualRow.index]; + if (!row) return null; + + return ( + + ); + })} +
+
+ ); +}; + +export const EventsTable = ({ query }: Props) => { + const { isLoading } = query; + const columns = useColumns(); + + const data = useMemo(() => { + if (isLoading) { + return LOADING_DATA; + } + + return query.data?.pages?.flatMap((p) => p.data) ?? []; + }, [query.data, isLoading]); + + const { columnVisibility, setColumnVisibility, columnOrder, setColumnOrder } = + useDataTableColumnVisibility(columns, 'events'); + + const table = useReactTable({ + data, + getCoreRowModel: getCoreRowModel(), + manualPagination: true, + manualFiltering: true, + manualSorting: true, + columns, + rowCount: 50, + pageCount: 1, + filterFns: { + isWithinRange: () => true, + }, + state: { + columnVisibility, + columnOrder, + }, + onColumnVisibilityChange: setColumnVisibility, + onColumnOrderChange: setColumnOrder, + }); + + const inViewportRef = useRef(null); + const { inViewport, enterCount } = useInViewport(inViewportRef, undefined, { + disconnectOnLeave: true, + }); + + const hasNextPage = last(query.data?.pages ?? [])?.meta.next; + + useEffect(() => { + if ( + hasNextPage && + data.length > 0 && + inViewport && + enterCount > 0 && + query.isFetchingNextPage === false + ) { + query.fetchNextPage(); + } + }, [inViewport, enterCount, hasNextPage]); + + return ( + <> + + +
+
+ +
+
+ + ); +}; + function EventsTableToolbar({ query, + table, }: { query: Props['query']; + table: Table; }) { const { projectId } = useAppParams(); const [startDate, setStartDate] = useQueryState( @@ -195,6 +304,7 @@ function EventsTableToolbar({ parseAsIsoDateTime, ); const [endDate, setEndDate] = useQueryState('endDate', parseAsIsoDateTime); + return (
@@ -225,74 +335,7 @@ function EventsTableToolbar({ />
- +
); } - -export function EventsViewOptions() { - const [viewOptions, setViewOptions] = useEventsViewOptions(); - const columns = { - origin: 'Show origin', - queryString: 'Show query string', - referrer: 'Referrer', - country: 'Country', - os: 'OS', - browser: 'Browser', - profileId: 'Profile', - createdAt: 'Created at', - properties: 'Properties', - }; - - return ( - - - - - - - - - - No columns found. - - {Object.entries(columns).map(([column, label]) => ( - - setViewOptions({ - ...viewOptions, - // biome-ignore lint/complexity/noUselessTernary: we need this this viewOptions[column] can be undefined - [column]: viewOptions[column] === false ? true : false, - }) - } - > - {label} - - - ))} - - - - - - - ); -} diff --git a/apps/start/src/components/login-left-panel.tsx b/apps/start/src/components/login-left-panel.tsx index 84bbd0b6..038a0324 100644 --- a/apps/start/src/components/login-left-panel.tsx +++ b/apps/start/src/components/login-left-panel.tsx @@ -15,7 +15,7 @@ const sellingPoints = [ ), }, diff --git a/apps/start/src/components/notifications/table/index.tsx b/apps/start/src/components/notifications/table/index.tsx index 42c9bba7..70f28e04 100644 --- a/apps/start/src/components/notifications/table/index.tsx +++ b/apps/start/src/components/notifications/table/index.tsx @@ -17,6 +17,7 @@ export const NotificationsTable = ({ query }: Props) => { const columns = useColumns(); const { data, isLoading } = query; const { table } = useTable({ + name: 'notifications', columns, data: data ?? [], loading: isLoading, diff --git a/apps/start/src/components/onboarding-left-panel.tsx b/apps/start/src/components/onboarding-left-panel.tsx index 14f7b74a..ffad3f61 100644 --- a/apps/start/src/components/onboarding-left-panel.tsx +++ b/apps/start/src/components/onboarding-left-panel.tsx @@ -42,7 +42,7 @@ const onboardingSellingPoints = [ ), }, diff --git a/apps/start/src/components/overview/overview-live-histogram.tsx b/apps/start/src/components/overview/overview-live-histogram.tsx index 397b0ee7..3e49f60c 100644 --- a/apps/start/src/components/overview/overview-live-histogram.tsx +++ b/apps/start/src/components/overview/overview-live-histogram.tsx @@ -2,8 +2,6 @@ import { useTRPC } from '@/integrations/trpc/react'; import { cn } from '@/utils/cn'; import { useQuery } from '@tanstack/react-query'; -import type { IChartProps } from '@openpanel/validation'; - import { useNumber } from '@/hooks/use-numer-formatter'; import { getChartColor } from '@/utils/theme'; import React, { useEffect, useState } from 'react'; @@ -19,6 +17,7 @@ import { YAxis, } from 'recharts'; import { BarShapeBlue } from '../charts/common-bar'; +import { SerieIcon } from '../report-chart/common/serie-icon'; interface OverviewLiveHistogramProps { projectId: string; @@ -27,72 +26,17 @@ interface OverviewLiveHistogramProps { export function OverviewLiveHistogram({ projectId, }: OverviewLiveHistogramProps) { - const report: IChartProps = { - projectId, - events: [ - { - segment: 'user', - filters: [ - { - id: '1', - name: 'name', - operator: 'is', - value: ['screen_view', 'session_start'], - }, - ], - id: 'A', - name: '*', - displayName: 'Active users', - }, - ], - chartType: 'histogram', - interval: 'minute', - range: '30min', - name: '', - metric: 'sum', - breakdowns: [], - lineType: 'monotone', - previous: false, - }; - const countReport: IChartProps = { - name: '', - projectId, - events: [ - { - segment: 'user', - filters: [], - id: 'A', - name: 'session_start', - }, - ], - breakdowns: [], - chartType: 'metric', - lineType: 'monotone', - interval: 'minute', - range: '30min', - previous: false, - metric: 'sum', - }; const trpc = useTRPC(); - const res = useQuery(trpc.chart.chart.queryOptions(report)); - const countRes = useQuery(trpc.chart.chart.queryOptions(countReport)); + // Use the new liveData endpoint instead of chart props + const { data: liveData, isLoading } = useQuery( + trpc.overview.liveData.queryOptions({ projectId }), + ); - const metrics = res.data?.series[0]?.metrics; - const minutes = (res.data?.series[0]?.data || []).slice(-30); - const liveCount = countRes.data?.series[0]?.metrics?.sum ?? 0; + const totalSessions = liveData?.totalSessions ?? 0; + const chartData = liveData?.minuteCounts ?? []; - // Transform data for Recharts - const chartData = minutes.map((minute) => ({ - ...minute, - timestamp: new Date(minute.date).getTime(), - time: new Date(minute.date).toLocaleTimeString([], { - hour: '2-digit', - minute: '2-digit', - }), - })); - - if (res.isInitialLoading || countRes.isInitialLoading) { + if (isLoading) { return (
@@ -100,12 +44,30 @@ export function OverviewLiveHistogram({ ); } - if (!res.isSuccess && !countRes.isSuccess) { + if (!liveData) { return null; } + const maxDomain = + Math.max(...chartData.map((item) => item.sessionCount)) * 1.2; + return ( - + + {liveData.referrers.slice(0, 3).map((ref, index) => ( +
+ + {ref.count} +
+ ))} +
+ } + >
- + -
- {count} unique vistors last 30 minutes +
+
+ {count} sessions last 30 minutes +
+
{icons}
{children} @@ -182,8 +148,8 @@ const CustomTooltip = ({ active, payload, coordinate }: any) => { const data = payload[0].payload; // Smart positioning to avoid going out of bounds - const tooltipWidth = 180; // min-w-[180px] - const tooltipHeight = 80; // approximate height + const tooltipWidth = 220; // min-w-[220px] to accommodate referrers + const tooltipHeight = 120; // approximate height with referrers const offset = 10; let left = mousePosition.x + offset; @@ -211,7 +177,7 @@ const CustomTooltip = ({ active, payload, coordinate }: any) => { const tooltipContent = (
{ }} >
-
- {new Date(data.date).toLocaleTimeString([], { - hour: '2-digit', - minute: '2-digit', - })} -
+
{data.time}
@@ -235,14 +196,43 @@ const CustomTooltip = ({ active, payload, coordinate }: any) => { style={{ background: getChartColor(0) }} />
-
Active users
+
Sessions
- {number.formatWithUnit(data.count)} + {number.formatWithUnit(data.sessionCount)}
+ {data.referrers && data.referrers.length > 0 && ( +
+
Referrers:
+
+ {data.referrers.slice(0, 3).map((ref: any, index: number) => ( +
+
+ + + {ref.referrer} + +
+ {ref.count} +
+ ))} + {data.referrers.length > 3 && ( +
+ +{data.referrers.length - 3} more +
+ )} +
+
+ )}
); diff --git a/apps/start/src/components/overview/overview-metrics.tsx b/apps/start/src/components/overview/overview-metrics.tsx index a6959981..644fe736 100644 --- a/apps/start/src/components/overview/overview-metrics.tsx +++ b/apps/start/src/components/overview/overview-metrics.tsx @@ -2,6 +2,7 @@ import { useOverviewOptions } from '@/components/overview/useOverviewOptions'; import { useEventQueryFilters } from '@/hooks/use-event-query-filters'; import { cn } from '@/utils/cn'; +import { useCookieStore } from '@/hooks/use-cookie-store'; import { useDashedStroke } from '@/hooks/use-dashed-stroke'; import { useFormatDateInterval } from '@/hooks/use-format-date-interval'; import { useNumber } from '@/hooks/use-numer-formatter'; @@ -18,11 +19,14 @@ import { Bar, CartesianGrid, ComposedChart, + Customized, Line, + LineChart, ResponsiveContainer, XAxis, YAxis, } from 'recharts'; +import { useLocalStorage } from 'usehooks-ts'; import { createChartTooltip } from '../charts/chart-tooltip'; import { BarShapeBlue, BarShapeGrey } from '../charts/common-bar'; import { useXAxisProps, useYAxisProps } from '../report-chart/common/axis'; @@ -80,6 +84,11 @@ export default function OverviewMetrics({ projectId }: OverviewMetricsProps) { const [filters] = useEventQueryFilters(); const trpc = useTRPC(); + const [chartType, setChartType] = useCookieStore<'bars' | 'lines'>( + 'chartType', + 'bars', + ); + const activeMetric = TITLES[metric]!; const overviewQuery = useQuery( trpc.overview.stats.queryOptions({ @@ -132,8 +141,36 @@ export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
-
- {activeMetric.title} +
+
+ {activeMetric.title} +
+
+ + +
{overviewQuery.isLoading && } @@ -141,6 +178,7 @@ export default function OverviewMetrics({ projectId }: OverviewMetricsProps) { activeMetric={activeMetric} interval={interval} data={data} + chartType={chartType} />
@@ -205,15 +243,142 @@ function Chart({ activeMetric, interval, data, + chartType, }: { activeMetric: (typeof TITLES)[number]; interval: IInterval; data: RouterOutputs['overview']['stats']['series']; + chartType: 'bars' | 'lines'; }) { const xAxisProps = useXAxisProps({ interval }); const yAxisProps = useYAxisProps(); const [activeBar, setActiveBar] = useState(-1); + // Line chart specific logic + let dotIndex = undefined; + if (chartType === 'lines') { + if (interval === 'hour') { + // Find closest index based on times + dotIndex = data.findIndex((item) => { + return isSameHour(item.date, new Date()); + }); + } + } + + const { calcStrokeDasharray, handleAnimationEnd, getStrokeDasharray } = + useDashedStroke({ + dotIndex, + }); + + const lastSerieDataItem = last(data)?.date || new Date(); + const useDashedLastLine = (() => { + if (interval === 'hour') { + return isSameHour(lastSerieDataItem, new Date()); + } + + if (interval === 'day') { + return isSameDay(lastSerieDataItem, new Date()); + } + + if (interval === 'month') { + return isSameMonth(lastSerieDataItem, new Date()); + } + + if (interval === 'week') { + return isSameWeek(lastSerieDataItem, new Date()); + } + + return false; + })(); + + if (chartType === 'lines') { + return ( + + + + + + + + + + + + 90 + ? false + : { + stroke: 'oklch(from var(--foreground) l c h / 0.1)', + fill: 'var(--def-100)', + strokeWidth: 1.5, + r: 2, + } + } + activeDot={{ + stroke: 'oklch(from var(--foreground) l c h / 0.2)', + fill: 'var(--def-100)', + strokeWidth: 1.5, + r: 3, + }} + /> + + 90 + ? false + : { + stroke: getChartColor(0), + fill: 'var(--def-100)', + strokeWidth: 1.5, + r: 3, + } + } + activeDot={{ + stroke: getChartColor(0), + fill: 'var(--def-100)', + strokeWidth: 2, + r: 4, + }} + /> + + + + ); + } + + // Bar chart (default) return ( diff --git a/apps/start/src/components/overview/overview-share.tsx b/apps/start/src/components/overview/overview-share.tsx index 0ec4a085..d24209b3 100644 --- a/apps/start/src/components/overview/overview-share.tsx +++ b/apps/start/src/components/overview/overview-share.tsx @@ -42,7 +42,11 @@ export function OverviewShare({ projectId }: OverviewShareProps) { return ( - diff --git a/apps/start/src/components/overview/useOverviewOptions.ts b/apps/start/src/components/overview/useOverviewOptions.ts index b995cacf..0455ddab 100644 --- a/apps/start/src/components/overview/useOverviewOptions.ts +++ b/apps/start/src/components/overview/useOverviewOptions.ts @@ -6,7 +6,7 @@ import { useQueryState, } from 'nuqs'; -import { getStorageItem, setStorageItem } from '@/utils/storage'; +import { useCookieStore } from '@/hooks/use-cookie-store'; import { getDefaultIntervalByDates, getDefaultIntervalByRange, @@ -27,10 +27,14 @@ export function useOverviewOptions() { 'end', parseAsString.withOptions(nuqsOptions), ); + const [cookieRange, setCookieRange] = useCookieStore( + 'range', + '7d', + ); const [range, setRange] = useQueryState( 'range', parseAsStringEnum(mapKeys(timeWindows)) - .withDefault(getStorageItem('range', '7d')) + .withDefault(cookieRange) .withOptions({ ...nuqsOptions, clearOnDefault: false, @@ -69,7 +73,9 @@ export function useOverviewOptions() { if (value !== 'custom') { setStartDate(null); setEndDate(null); - setStorageItem('range', value); + if (value) { + setCookieRange(value); + } setInterval(null); } setRange(value); diff --git a/apps/start/src/components/profiles/latest-events.tsx b/apps/start/src/components/profiles/latest-events.tsx index 9967d43a..fec02e69 100644 --- a/apps/start/src/components/profiles/latest-events.tsx +++ b/apps/start/src/components/profiles/latest-events.tsx @@ -4,8 +4,8 @@ import { useTRPC } from '@/integrations/trpc/react'; import { useQuery } from '@tanstack/react-query'; import { useRouter } from '@tanstack/react-router'; import { ActivityIcon } from 'lucide-react'; -import { EventsViewOptions, useEventsViewOptions } from '../events/table'; -import { EventItem } from '../events/table/item'; +import { useEffect, useRef } from 'react'; +import { EventListItem } from '../events/event-list-item'; import { WidgetAbsoluteButtons, WidgetHead, @@ -24,7 +24,6 @@ export const LatestEvents = ({ projectId, organizationId, }: Props) => { - const [viewOptions] = useEventsViewOptions(); const router = useRouter(); const trpc = useTRPC(); const query = useQuery( @@ -45,26 +44,30 @@ export const LatestEvents = ({ }); }; + const ref = useRef(null); + const scrollRef = useRef(null); + useEffect(() => { + if (ref.current && scrollRef.current) { + scrollRef.current.style.height = `${ref.current?.getBoundingClientRect().height}px`; + } + }, [query.data?.data?.length]); + return ( - + Latest Events - - + {query.data?.data?.map((event) => ( - +
+ +
))}
diff --git a/apps/start/src/components/profiles/table/index.tsx b/apps/start/src/components/profiles/table/index.tsx index daa4d3b7..df46040e 100644 --- a/apps/start/src/components/profiles/table/index.tsx +++ b/apps/start/src/components/profiles/table/index.tsx @@ -31,8 +31,12 @@ export const ProfilesTable = memo( const columns = useColumns(type); const { setPage, state: pagination } = useDataTablePagination(); - const { columnVisibility, setColumnVisibility } = - useDataTableColumnVisibility(columns); + const { + columnVisibility, + setColumnVisibility, + columnOrder, + setColumnOrder, + } = useDataTableColumnVisibility(columns, 'profiles'); const table = useReactTable({ data: isLoading ? LOADING_DATA : (data?.data ?? []), @@ -51,8 +55,10 @@ export const ProfilesTable = memo( state: { pagination, columnVisibility, + columnOrder, }, onColumnVisibilityChange: setColumnVisibility, + onColumnOrderChange: setColumnOrder, onPaginationChange: (updaterOrValue: Updater) => { const nextPagination = typeof updaterOrValue === 'function' diff --git a/apps/start/src/components/sessions/table/index.tsx b/apps/start/src/components/sessions/table/index.tsx index 5115b255..fbf1920a 100644 --- a/apps/start/src/components/sessions/table/index.tsx +++ b/apps/start/src/components/sessions/table/index.tsx @@ -2,8 +2,41 @@ 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'; +// Custom hook for persistent column visibility +const usePersistentColumnVisibility = (columns: any[]) => { + const [savedVisibility, setSavedVisibility] = useLocalStorage< + Record + >('@op:sessions-table-column-visibility', {}); + + // Create column visibility from saved state, defaulting to true (visible) + const columnVisibility = useMemo(() => { + return columns.reduce( + (acc, column) => { + const columnId = column.id || column.accessorKey; + if (columnId) { + acc[columnId] = savedVisibility[columnId] ?? true; + } + return acc; + }, + {} as Record, + ); + }, [columns, savedVisibility]); + + const handleColumnVisibilityChange = (updater: any) => { + const newVisibility = + typeof updater === 'function' ? updater(columnVisibility) : updater; + setSavedVisibility(newVisibility); + }; + + return { + columnVisibility, + setColumnVisibility: handleColumnVisibilityChange, + }; +}; + import { FullPageEmptyState } from '@/components/full-page-empty-state'; import { Skeleton } from '@/components/skeleton'; import { @@ -21,7 +54,7 @@ import { useWindowVirtualizer } from '@tanstack/react-virtual'; import type { TRPCInfiniteData } from '@trpc/tanstack-react-query'; import { Loader2Icon } from 'lucide-react'; import { last } from 'ramda'; -import { memo, useEffect, useMemo, useRef } from 'react'; +import { memo, useEffect, useMemo, useRef, useState } from 'react'; import { useInViewport } from 'react-in-viewport'; type Props = { @@ -49,6 +82,7 @@ interface VirtualRowProps { headerColumns: any[]; scrollMargin: number; isLoading: boolean; + headerColumnsHash: string; } const VirtualRow = memo( @@ -109,109 +143,105 @@ const VirtualRow = memo( prevProps.virtualRow.index === nextProps.virtualRow.index && prevProps.virtualRow.start === nextProps.virtualRow.start && prevProps.virtualRow.size === nextProps.virtualRow.size && - prevProps.isLoading === nextProps.isLoading + prevProps.isLoading === nextProps.isLoading && + prevProps.headerColumnsHash === nextProps.headerColumnsHash ); }, ); -const VirtualizedSessionsTable = memo( - function VirtualizedSessionsTable({ - table, - data, - isLoading, - }: VirtualizedSessionsTableProps) { - const parentRef = useRef(null); +const VirtualizedSessionsTable = ({ + table, + data, + isLoading, +}: VirtualizedSessionsTableProps) => { + const parentRef = useRef(null); - const headerColumns = useMemo( - () => - table.getAllLeafColumns().filter((col) => { - return table.getState().columnVisibility[col.id] !== false; - }), - [table], - ); + const headerColumns = table.getAllLeafColumns().filter((col) => { + return table.getState().columnVisibility[col.id] !== false; + }); - const rowVirtualizer = useWindowVirtualizer({ - count: data.length, - estimateSize: () => ROW_HEIGHT, // Estimated row height - overscan: 10, - scrollMargin: parentRef.current?.offsetTop ?? 0, - }); + const rowVirtualizer = useWindowVirtualizer({ + count: data.length, + estimateSize: () => ROW_HEIGHT, // Estimated row height + overscan: 10, + scrollMargin: parentRef.current?.offsetTop ?? 0, + }); - const virtualRows = rowVirtualizer.getVirtualItems(); + const virtualRows = rowVirtualizer.getVirtualItems(); + const headerColumnsHash = headerColumns.map((col) => col.id).join(','); - return ( + return ( +
+ {/* Table Header */}
`${col.getSize()}px`) + .join(' '), + minWidth: 'fit-content', + }} > - {/* Table Header */} -
`${col.getSize()}px`) - .join(' '), - minWidth: 'fit-content', - }} - > - {headerColumns.map((column) => { - const header = column.columnDef.header; - const width = `${column.getSize()}px`; - return ( -
- {typeof header === 'function' ? header({} as any) : header} -
- ); - })} -
- - {!isLoading && data.length === 0 && ( - - )} - - {/* Table Body */} -
- {virtualRows.map((virtualRow) => { - const row = table.getRowModel().rows[virtualRow.index]; - if (!row) return null; - - return ( - - ); - })} -
+ {headerColumns.map((column) => { + const header = column.columnDef.header; + const width = `${column.getSize()}px`; + return ( +
+ {typeof header === 'function' ? header({} as any) : header} +
+ ); + })}
- ); - }, - arePropsEqual(['data', 'isLoading']), -); + + {!isLoading && data.length === 0 && ( + + )} + + {/* Table Body */} +
+ {virtualRows.map((virtualRow) => { + const row = table.getRowModel().rows[virtualRow.index]; + if (!row) return null; + + return ( + + ); + })} +
+
+ ); +}; export const SessionsTable = ({ query }: Props) => { const { isLoading } = query; @@ -227,7 +257,7 @@ export const SessionsTable = ({ query }: Props) => { // const { setPage, state: pagination } = useDataTablePagination(); const { columnVisibility, setColumnVisibility } = - useDataTableColumnVisibility(columns); + usePersistentColumnVisibility(columns); const table = useReactTable({ data, diff --git a/apps/start/src/components/settings/invites/index.tsx b/apps/start/src/components/settings/invites/index.tsx index 14d53785..4017d168 100644 --- a/apps/start/src/components/settings/invites/index.tsx +++ b/apps/start/src/components/settings/invites/index.tsx @@ -18,6 +18,7 @@ export const InvitesTable = ({ query }: Props) => { const columns = useColumns(); const { data, isLoading } = query; const { table } = useTable({ + name: 'invites', columns, data: data ?? [], loading: isLoading, diff --git a/apps/start/src/components/settings/members/index.tsx b/apps/start/src/components/settings/members/index.tsx index 520f5247..60f5340a 100644 --- a/apps/start/src/components/settings/members/index.tsx +++ b/apps/start/src/components/settings/members/index.tsx @@ -16,6 +16,7 @@ export const MembersTable = ({ query }: Props) => { const columns = useColumns(); const { data, isLoading } = query; const { table } = useTable({ + name: 'members', columns, data: data ?? [], loading: isLoading, diff --git a/apps/start/src/components/ui/data-table/data-table-hooks.tsx b/apps/start/src/components/ui/data-table/data-table-hooks.tsx index fa9944a7..a76a9189 100644 --- a/apps/start/src/components/ui/data-table/data-table-hooks.tsx +++ b/apps/start/src/components/ui/data-table/data-table-hooks.tsx @@ -5,6 +5,7 @@ import type { } from '@tanstack/react-table'; import { parseAsInteger, useQueryState } from 'nuqs'; import { useState } from 'react'; +import { useLocalStorage } from 'usehooks-ts'; export const useDataTablePagination = (pageSize = 10) => { const [page, setPage] = useQueryState( @@ -23,12 +24,29 @@ export const useDataTablePagination = (pageSize = 10) => { export const useDataTableColumnVisibility = ( columns: ColumnDef[], + persistentKey: string, ) => { - const [columnVisibility, setColumnVisibility] = useState( + const [columnVisibility, setColumnVisibility] = useLocalStorage< + Record + >( + `@op:${persistentKey}-column-visibility`, columns.reduce((acc, column) => { - acc[column.id!] = column.meta?.hidden ?? false; + // Use accessorKey as fallback if id is not provided + const columnId = column.id || (column as any).accessorKey; + if (columnId) { + acc[columnId] = + typeof column.meta?.hidden === 'boolean' + ? !column.meta?.hidden + : true; + } return acc; }, {} as VisibilityState), ); - return { columnVisibility, setColumnVisibility }; + + const [columnOrder, setColumnOrder] = useLocalStorage( + `@op:${persistentKey}-column-order`, + columns.map((column) => column.id!), + ); + + return { columnVisibility, setColumnVisibility, columnOrder, setColumnOrder }; }; diff --git a/apps/start/src/components/ui/data-table/data-table-view-options.tsx b/apps/start/src/components/ui/data-table/data-table-view-options.tsx index cbe2eb39..00776266 100644 --- a/apps/start/src/components/ui/data-table/data-table-view-options.tsx +++ b/apps/start/src/components/ui/data-table/data-table-view-options.tsx @@ -13,28 +13,162 @@ import { PopoverTrigger, } from '@/components/ui/popover'; import { cn } from '@/lib/utils'; +import { + DndContext, + type DragEndEvent, + KeyboardSensor, + PointerSensor, + closestCenter, + useSensor, + useSensors, +} from '@dnd-kit/core'; +import { + SortableContext, + sortableKeyboardCoordinates, + useSortable, + verticalListSortingStrategy, +} from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; import type { Table } from '@tanstack/react-table'; -import { Check, ChevronsUpDown, Settings2Icon } from 'lucide-react'; +import { + Check, + ChevronsUpDown, + GripVertical, + RotateCcw, + Settings2Icon, +} from 'lucide-react'; import * as React from 'react'; interface DataTableViewOptionsProps { table: Table; } +interface SortableColumnItemProps { + column: any; + onToggleVisibility: () => void; +} + +function SortableColumnItem({ + column, + onToggleVisibility, +}: SortableColumnItemProps) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: column.id }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + }; + + return ( + +
+ +
+ + {typeof column.columnDef.header === 'string' + ? column.columnDef.header + : (column.columnDef.meta?.label ?? column.id)} + + +
+ ); +} + export function DataTableViewOptions({ table, }: DataTableViewOptionsProps) { - const columns = React.useMemo( - () => - table - .getAllColumns() - .filter( - (column) => - typeof column.accessorFn !== 'undefined' && column.getCanHide(), - ), - [table], + const allColumns = table.getAllColumns(); + const filterableColumns = allColumns.filter( + (column) => typeof column.accessorFn !== 'undefined' && column.getCanHide(), ); + // Use the column order from the table state (managed by useDataTableColumnVisibility) + const columns = React.useMemo(() => { + const columnMap = new Map(filterableColumns.map((col) => [col.id, col])); + const orderedColumns: typeof filterableColumns = []; + const currentColumnOrder = table.getState().columnOrder; + + // Add columns in the current table order + currentColumnOrder.forEach((columnId) => { + const column = columnMap.get(columnId); + if (column) { + orderedColumns.push(column); + columnMap.delete(columnId); + } + }); + + // Add any new columns that weren't in the current order + columnMap.forEach((column) => { + orderedColumns.push(column); + }); + + return orderedColumns; + }, [filterableColumns, table]); + + const sensors = useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + + if (active.id !== over?.id) { + const oldIndex = columns.findIndex((column) => column.id === active.id); + const newIndex = columns.findIndex((column) => column.id === over?.id); + + if (oldIndex !== -1 && newIndex !== -1) { + // Reorder the columns in the table + const newColumns = [...columns]; + const [removed] = newColumns.splice(oldIndex, 1); + newColumns.splice(newIndex, 0, removed); + + // Update the table column order (this will automatically persist via useDataTableColumnVisibility) + table.setColumnOrder(newColumns.map((col) => col.id)); + } + } + }; + + const handleReset = () => { + // Reset column visibility to default (all visible) + allColumns.forEach((column) => { + if (column.getCanHide()) { + column.toggleVisibility( + typeof column.columnDef.meta?.hidden === 'boolean' + ? !column.columnDef.meta?.hidden + : true, + ); + } + }); + + // Reset column order to default (this will automatically persist via useDataTableColumnVisibility) + const defaultOrder = filterableColumns.map((col) => col.id); + table.setColumnOrder(defaultOrder); + }; + return ( @@ -50,32 +184,41 @@ export function DataTableViewOptions({ - + No columns found. - {columns.map((column) => ( - - column.toggleVisibility(!column.getIsVisible()) - } + + col.id)} + strategy={verticalListSortingStrategy} > - - {typeof column.columnDef.header === 'string' - ? column.columnDef.header - : (column.columnDef.meta?.label ?? column.id)} - - - - ))} + {columns.map((column) => ( + + column.toggleVisibility(!column.getIsVisible()) + } + /> + ))} + + + + + + + Reset to default + diff --git a/apps/start/src/components/ui/data-table/use-table.tsx b/apps/start/src/components/ui/data-table/use-table.tsx index 8e453e65..65705b92 100644 --- a/apps/start/src/components/ui/data-table/use-table.tsx +++ b/apps/start/src/components/ui/data-table/use-table.tsx @@ -23,6 +23,7 @@ import { useQueryStates, } from 'nuqs'; import React, { useMemo, useState } from 'react'; +import { useDataTableColumnVisibility } from './data-table-hooks'; const nuqsOptions: Options = { shallow: true, @@ -35,11 +36,13 @@ export function useTable({ pageSize, data, loading, + name, }: { columns: ColumnDef[]; pageSize: number; data: TData[]; loading: boolean; + name: string; }) { const [page, setPage] = useQueryState( 'page', @@ -54,6 +57,9 @@ export function useTable({ pageSize: perPage, }; + const { columnVisibility, setColumnVisibility, columnOrder, setColumnOrder } = + useDataTableColumnVisibility(columns, name); + const [columnPinning, setColumnPinning] = useState({ left: [ ...columns @@ -149,6 +155,9 @@ export function useTable({ }; const table = useReactTable({ + meta: { + name, + }, columns, data: useMemo( () => @@ -181,7 +190,11 @@ export function useTable({ pagination, columnPinning, columnFilters: loading ? [] : columnFilters, + columnOrder, + columnVisibility, }, + onColumnOrderChange: setColumnOrder, + onColumnVisibilityChange: setColumnVisibility, onColumnPinningChange: setColumnPinning, onColumnFiltersChange: (updaterOrValue: Updater) => { setColumnFilters((prev) => { diff --git a/apps/start/src/components/widget.tsx b/apps/start/src/components/widget.tsx index 06a018ae..96a5e22c 100644 --- a/apps/start/src/components/widget.tsx +++ b/apps/start/src/components/widget.tsx @@ -57,7 +57,12 @@ export function WidgetBody({ children, className }: WidgetBodyProps) { export interface WidgetProps { children: React.ReactNode; className?: string; + ref?: React.RefObject; } -export function Widget({ children, className }: WidgetProps) { - return
{children}
; +export function Widget({ children, className, ...props }: WidgetProps) { + return ( +
+ {children} +
+ ); } diff --git a/apps/start/src/hooks/use-cookie-store.tsx b/apps/start/src/hooks/use-cookie-store.tsx new file mode 100644 index 00000000..032f3636 --- /dev/null +++ b/apps/start/src/hooks/use-cookie-store.tsx @@ -0,0 +1,35 @@ +import { useRouteContext } from '@tanstack/react-router'; +import { createServerFn, createServerOnlyFn } from '@tanstack/react-start'; +import { getCookies, setCookie } from '@tanstack/react-start/server'; +import { useMemo, useState } from 'react'; +import { z } from 'zod'; + +const setCookieFn = createServerFn({ method: 'POST' }) + .inputValidator(z.object({ key: z.string(), value: z.string() })) + .handler(({ data: { key, value } }) => { + setCookie(key, value); + }); + +// Called in __root.tsx beforeLoad hook to get cookies from the server +// And recieved with useRouteContext in the client +export const getCookiesFn = createServerFn({ method: 'GET' }).handler(() => + getCookies(), +); + +export function useCookieStore(key: string, defaultValue: T) { + const { cookies } = useRouteContext({ strict: false }); + const [value, setValue] = useState((cookies?.[key] ?? defaultValue) as T); + + return useMemo( + () => + [ + value, + (value: T) => { + console.log('setting cookie', key, value); + setValue(value); + setCookieFn({ data: { key, value: String(value) } }); + }, + ] as const, + [value, key], + ); +} diff --git a/apps/start/src/integrations/tanstack-query/root-provider.tsx b/apps/start/src/integrations/tanstack-query/root-provider.tsx index 2e53bdf2..1c98cb9b 100644 --- a/apps/start/src/integrations/tanstack-query/root-provider.tsx +++ b/apps/start/src/integrations/tanstack-query/root-provider.tsx @@ -15,7 +15,11 @@ export const getIsomorphicHeaders = createIsomorphicFn() .server(() => { return getRequestHeaders(); }) - .client(() => ({}) as Headers); + .client(() => { + const headers = new Headers(); + headers.set('content-type', 'application/json'); + return headers as Headers; + }); // Create a function that returns a tRPC client with optional cookies export function createTRPCClientWithHeaders(apiUrl: string) { diff --git a/apps/start/src/routes/__root.tsx b/apps/start/src/routes/__root.tsx index d40161dd..337c4284 100644 --- a/apps/start/src/routes/__root.tsx +++ b/apps/start/src/routes/__root.tsx @@ -17,9 +17,16 @@ import FullPageLoadingState from '@/components/full-page-loading-state'; import { Providers } from '@/components/providers'; import { ThemeScriptOnce } from '@/components/theme-provider'; import { LinkButton } from '@/components/ui/button'; +import { getCookiesFn } from '@/hooks/use-cookie-store'; import { useSessionExtension } from '@/hooks/use-session-extension'; import { op } from '@/utils/op'; import type { AppRouter } from '@openpanel/trpc'; +import { createServerOnlyFn } from '@tanstack/react-start'; +import { + getCookie, + getCookies, + getRequestHeaders, +} from '@tanstack/react-start/server'; import type { TRPCOptionsProxy } from '@trpc/tanstack-react-query'; op.init(); @@ -33,17 +40,20 @@ interface MyRouterContext { export const Route = createRootRouteWithContext()({ beforeLoad: async ({ context }) => { - const session = await context.queryClient.ensureQueryData( - context.trpc.auth.session.queryOptions(undefined, { - staleTime: 1000 * 60 * 5, - gcTime: 1000 * 60 * 10, - refetchOnWindowFocus: false, - refetchOnMount: false, - refetchOnReconnect: false, - }), - ); + const [session, cookies] = await Promise.all([ + context.queryClient.ensureQueryData( + context.trpc.auth.session.queryOptions(undefined, { + staleTime: 1000 * 60 * 5, + gcTime: 1000 * 60 * 10, + refetchOnWindowFocus: false, + refetchOnMount: false, + refetchOnReconnect: false, + }), + ), + getCookiesFn(), + ]); - return { session }; + return { session, cookies }; }, head: () => ({ meta: [ diff --git a/apps/start/src/routes/_app.$organizationId.$projectId_.profiles.$profileId._tabs.tsx b/apps/start/src/routes/_app.$organizationId.$projectId_.profiles.$profileId._tabs.tsx index d87255df..a5d8f2c1 100644 --- a/apps/start/src/routes/_app.$organizationId.$projectId_.profiles.$profileId._tabs.tsx +++ b/apps/start/src/routes/_app.$organizationId.$projectId_.profiles.$profileId._tabs.tsx @@ -59,13 +59,15 @@ function Component() { +
- {getProfileName(profile.data, false)} + + {getProfileName(profile.data, false)} +
} > -
+
{profile.data?.properties.country && (
diff --git a/apps/start/src/routes/_app.$organizationId.$projectId_.references.tsx b/apps/start/src/routes/_app.$organizationId.$projectId_.references.tsx index dc5e88a8..186d3c34 100644 --- a/apps/start/src/routes/_app.$organizationId.$projectId_.references.tsx +++ b/apps/start/src/routes/_app.$organizationId.$projectId_.references.tsx @@ -163,6 +163,7 @@ function Component() { const data = query.data ?? []; const { table, loading } = useTable({ + name: 'references', columns: columnDefs, data, pageSize: 30, diff --git a/apps/start/src/utils/storage.ts b/apps/start/src/utils/storage.ts deleted file mode 100644 index 9eed2ff5..00000000 --- a/apps/start/src/utils/storage.ts +++ /dev/null @@ -1,23 +0,0 @@ -const prefix = '@op'; - -export function getStorageItem(key: string): T | null; -export function getStorageItem(key: string, defaultValue: T): T; -export function getStorageItem(key: string, defaultValue?: T): T | null { - if (typeof window === 'undefined') return defaultValue ?? null; - const item = localStorage.getItem(`${prefix}:${key}`); - if (item === null) { - return defaultValue ?? null; - } - - return item as T; -} - -export function setStorageItem(key: string, value: unknown) { - if (typeof window === 'undefined') return; - localStorage.setItem(`${prefix}:${key}`, value as string); -} - -export function removeStorageItem(key: string) { - if (typeof window === 'undefined') return; - localStorage.removeItem(`${prefix}:${key}`); -} diff --git a/packages/db/src/clickhouse/query-builder.ts b/packages/db/src/clickhouse/query-builder.ts index 78942278..946cff0b 100644 --- a/packages/db/src/clickhouse/query-builder.ts +++ b/packages/db/src/clickhouse/query-builder.ts @@ -247,11 +247,16 @@ export class Query { } // Fill - fill(from: string | Date, to: string | Date, step: string): this { + fill( + from: string | Date | Expression, + to: string | Date | Expression, + step: string | Expression, + ): this { this._fill = { - from: this.escapeDate(from), - to: this.escapeDate(to), - step: step, + from: + from instanceof Expression ? from.toString() : this.escapeDate(from), + to: to instanceof Expression ? to.toString() : this.escapeDate(to), + step: step instanceof Expression ? step.toString() : step, }; return this; } diff --git a/packages/db/src/services/overview.service.ts b/packages/db/src/services/overview.service.ts index a3596da1..e0897c17 100644 --- a/packages/db/src/services/overview.service.ts +++ b/packages/db/src/services/overview.service.ts @@ -165,16 +165,6 @@ export class OverviewService { views_per_session: number; }[]; }> { - console.log('-----------------'); - console.log('getMetrics', { - projectId, - filters, - startDate, - endDate, - interval, - timezone, - }); - const where = this.getRawWhereClause('sessions', filters); if (this.isPageFilter(filters)) { // Session aggregation with bounce rates diff --git a/packages/trpc/src/routers/overview.ts b/packages/trpc/src/routers/overview.ts index 881415fc..60855db8 100644 --- a/packages/trpc/src/routers/overview.ts +++ b/packages/trpc/src/routers/overview.ts @@ -1,4 +1,7 @@ import { + TABLE_NAMES, + ch, + clix, eventBuffer, getChartPrevStartEndDate, getChartStartEndDate, @@ -14,8 +17,12 @@ import { format } from 'date-fns'; import { z } from 'zod'; import { cacheMiddleware, createTRPCRouter, publicProcedure } from '../trpc'; -const cacher = cacheMiddleware((input) => { +const cacher = cacheMiddleware((input, opts) => { const range = input.range as IChartRange; + if (opts.path === 'overview.liveData') { + return 0; + } + switch (range) { case '30min': case 'today': @@ -82,6 +89,125 @@ export const overviewRouter = createTRPCRouter({ .query(async ({ input }) => { return eventBuffer.getActiveVisitorCount(input.projectId); }), + + liveData: publicProcedure + .input(z.object({ projectId: z.string() })) + .use(cacher) + .query(async ({ input }) => { + const { timezone } = await getSettingsForProject(input.projectId); + + // Get total unique sessions in the last 30 minutes + const totalSessionsQuery = clix(ch, timezone) + .select<{ total_sessions: number }>([ + 'uniq(session_id) as total_sessions', + ]) + .from(TABLE_NAMES.events) + .where('project_id', '=', input.projectId) + .where('name', '=', 'session_start') + .where('created_at', '>=', clix.exp('now() - INTERVAL 30 MINUTE')); + + // Get counts per minute for the last 30 minutes + const minuteCountsQuery = clix(ch, timezone) + .select<{ + minute: string; + session_count: number; + visitor_count: number; + }>([ + `${clix.toStartOf('created_at', 'minute')} as minute`, + 'uniq(session_id) as session_count', + 'uniq(profile_id) as visitor_count', + ]) + .from(TABLE_NAMES.events) + .where('project_id', '=', input.projectId) + .where('name', 'IN', ['session_start', 'screen_view']) + .where('created_at', '>=', clix.exp('now() - INTERVAL 30 MINUTE')) + .groupBy(['minute']) + .orderBy('minute', 'ASC') + .fill( + clix.exp('now() - INTERVAL 30 MINUTE'), + clix.exp('now()'), + clix.exp('INTERVAL 1 MINUTE'), + ); + + // Get referrers per minute for the last 30 minutes + const minuteReferrersQuery = clix(ch, timezone) + .select<{ + minute: string; + referrer_name: string; + count: number; + }>([ + `${clix.toStartOf('created_at', 'minute')} as minute`, + 'referrer_name', + 'count(*) as count', + ]) + .from(TABLE_NAMES.events) + .where('project_id', '=', input.projectId) + .where('name', '=', 'session_start') + .where('created_at', '>=', clix.exp('now() - INTERVAL 30 MINUTE')) + .where('referrer_name', '!=', '') + .where('referrer_name', 'IS NOT NULL') + .groupBy(['minute', 'referrer_name']) + .orderBy('minute', 'ASC') + .orderBy('count', 'DESC'); + + // Get unique referrers in the last 30 minutes + const referrersQuery = clix(ch, timezone) + .select<{ referrer: string; count: number }>([ + 'referrer_name as referrer', + 'count(*) as count', + ]) + .from(TABLE_NAMES.events) + .where('project_id', '=', input.projectId) + .where('name', '=', 'session_start') + .where('created_at', '>=', clix.exp('now() - INTERVAL 30 MINUTE')) + .where('referrer_name', '!=', '') + .where('referrer_name', 'IS NOT NULL') + .groupBy(['referrer_name']) + .orderBy('count', 'DESC') + .limit(10); + + const [totalSessions, minuteCounts, minuteReferrers, referrers] = + await Promise.all([ + totalSessionsQuery.execute(), + minuteCountsQuery.execute(), + minuteReferrersQuery.execute(), + referrersQuery.execute(), + ]); + + // Group referrers by minute + const referrersByMinute = new Map< + string, + Array<{ referrer: string; count: number }> + >(); + minuteReferrers.forEach((item) => { + if (!referrersByMinute.has(item.minute)) { + referrersByMinute.set(item.minute, []); + } + referrersByMinute.get(item.minute)!.push({ + referrer: item.referrer_name, + count: item.count, + }); + }); + + return { + totalSessions: totalSessions[0]?.total_sessions || 0, + minuteCounts: minuteCounts.map((item) => ({ + minute: item.minute, + sessionCount: item.session_count, + visitorCount: item.visitor_count, + timestamp: new Date(item.minute).getTime(), + time: new Date(item.minute).toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + }), + referrers: referrersByMinute.get(item.minute) || [], + })), + referrers: referrers.map((item) => ({ + referrer: item.referrer, + count: item.count, + })), + }; + }), stats: publicProcedure .input( zGetMetricsInput.omit({ startDate: true, endDate: true }).extend({ diff --git a/packages/trpc/src/trpc.ts b/packages/trpc/src/trpc.ts index 652816fb..bd8c1ad7 100644 --- a/packages/trpc/src/trpc.ts +++ b/packages/trpc/src/trpc.ts @@ -169,8 +169,15 @@ const middlewareMarker = 'middlewareMarker' as 'middlewareMarker' & { __brand: 'middlewareMarker'; }; -export const cacheMiddleware = (cbOrTtl: number | ((input: any) => number)) => +export const cacheMiddleware = ( + cbOrTtl: number | ((input: any, opts: { path: string }) => number), +) => t.middleware(async ({ ctx, next, path, type, getRawInput, input }) => { + const ttl = + typeof cbOrTtl === 'function' ? cbOrTtl(input, { path }) : cbOrTtl; + if (!ttl) { + return next(); + } const rawInput = await getRawInput(); if (type !== 'query') { return next(); @@ -194,7 +201,7 @@ export const cacheMiddleware = (cbOrTtl: number | ((input: any) => number)) => if (result.data) { getRedisCache().setJson( key, - typeof cbOrTtl === 'function' ? cbOrTtl(input) : cbOrTtl, + ttl, // @ts-expect-error result.data, );