fix: improvements in the dashboard
This commit is contained in:
@@ -18,6 +18,7 @@ export const ClientsTable = ({ query }: Props) => {
|
||||
const { data, isLoading } = query;
|
||||
|
||||
const { table } = useTable({
|
||||
name: 'clients',
|
||||
columns,
|
||||
data: data ?? [],
|
||||
loading: isLoading,
|
||||
|
||||
@@ -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<IServiceEvent>[] = [
|
||||
{
|
||||
accessorKey: 'createdAt',
|
||||
header: 'Created at',
|
||||
size: 140,
|
||||
cell: ({ row }) => {
|
||||
const session = row.original;
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 opacity-0 group-hover/row:opacity-100 transition-opacity duration-100">
|
||||
{formatDateTime(session.createdAt)}
|
||||
</div>
|
||||
<div className="text-muted-foreground group-hover/row:opacity-0 transition-opacity duration-100">
|
||||
{formatTimeAgoOrDateTime(session.createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
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 (
|
||||
<div>{isToday(date) ? formatTime(date) : formatDateTime(date)}</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
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 (
|
||||
<div className="row flex-wrap gap-x-4 gap-y-1 overflow-hidden text-sm">
|
||||
{items.slice(0, 4).map(([key, value]) => (
|
||||
<div key={key} className="row items-center gap-1 min-w-0">
|
||||
<span className="text-muted-foreground">{key}</span>
|
||||
<span className="truncate font-medium">{String(value)}</span>
|
||||
</div>
|
||||
))}
|
||||
{items.length > 5 && (
|
||||
<span className="truncate">{items.length - 5} more</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
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 <KeyValueGrid className="w-full" data={data} />;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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<Record<string, boolean | undefined>>(
|
||||
'@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<HTMLDivElement>(null);
|
||||
const [scrollMargin, setScrollMargin] = useState(0);
|
||||
const inViewportRef = useRef<HTMLDivElement>(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<IServiceEvent>;
|
||||
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 (
|
||||
<>
|
||||
<EventsTableToolbar query={query} />
|
||||
<div ref={parentRef} className="w-full">
|
||||
{isLoading && (
|
||||
<div className="w-full gap-2 col">
|
||||
<EventItemSkeleton />
|
||||
<EventItemSkeleton />
|
||||
<EventItemSkeleton />
|
||||
<EventItemSkeleton />
|
||||
<EventItemSkeleton />
|
||||
<EventItemSkeleton />
|
||||
<div
|
||||
key={virtualRow.key}
|
||||
data-index={virtualRow.index}
|
||||
ref={virtualRow.measureElement}
|
||||
className="absolute top-0 left-0 w-full border-b hover:bg-muted/50 transition-colors group/row"
|
||||
style={{
|
||||
transform: `translateY(${virtualRow.start - scrollMargin}px)`,
|
||||
display: 'grid',
|
||||
gridTemplateColumns: headerColumns
|
||||
.map((col) => `${col.getSize()}px`)
|
||||
.join(' '),
|
||||
minWidth: 'fit-content',
|
||||
minHeight: ROW_HEIGHT,
|
||||
}}
|
||||
>
|
||||
{row.getVisibleCells().map((cell: any) => {
|
||||
const width = `${cell.column.getSize()}px`;
|
||||
return (
|
||||
<div
|
||||
key={cell.id}
|
||||
className="flex items-center p-2 px-4 align-middle whitespace-nowrap"
|
||||
style={{
|
||||
width,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-4 w-3/5" />
|
||||
) : 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)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && data.length === 0 && (
|
||||
<FullPageEmptyState
|
||||
title="No events"
|
||||
description={"Start sending events and you'll see them here"}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
height: `${virtualizer.getTotalSize()}px`,
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{visibleItems.map((virtualRow) => (
|
||||
<div
|
||||
key={virtualRow.index}
|
||||
data-index={virtualRow.index}
|
||||
ref={virtualizer.measureElement}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
transform: `translateY(${
|
||||
virtualRow.start - virtualizer.options.scrollMargin
|
||||
}px)`,
|
||||
paddingBottom: '8px', // Gap between items
|
||||
}}
|
||||
>
|
||||
<EventItem
|
||||
event={data[virtualRow.index]!}
|
||||
viewOptions={viewOptions}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full h-10 center-center pt-4" ref={inViewportRef}>
|
||||
<div
|
||||
className={cn(
|
||||
'size-8 bg-background rounded-full center-center border opacity-0 transition-opacity',
|
||||
query.isFetchingNextPage && 'opacity-100',
|
||||
)}
|
||||
>
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
(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<HTMLDivElement>(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 (
|
||||
<div
|
||||
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"
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: headerColumns
|
||||
.map((col) => `${col.getSize()}px`)
|
||||
.join(' '),
|
||||
minWidth: 'fit-content',
|
||||
}}
|
||||
>
|
||||
{headerColumns.map((column) => {
|
||||
const header = column.columnDef.header;
|
||||
const width = `${column.getSize()}px`;
|
||||
return (
|
||||
<div
|
||||
key={column.id}
|
||||
className="flex items-center h-10 px-4 text-left text-[10px] uppercase text-foreground font-semibold whitespace-nowrap"
|
||||
style={{
|
||||
width,
|
||||
}}
|
||||
>
|
||||
{typeof header === 'function' ? header({} as any) : header}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{!isLoading && data.length === 0 && (
|
||||
<FullPageEmptyState
|
||||
title="No events"
|
||||
description={"Start sending events and you'll see them here"}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Table Body */}
|
||||
<div
|
||||
className="relative w-full"
|
||||
style={{
|
||||
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||
minHeight: 'fit-content',
|
||||
minWidth: 'fit-content',
|
||||
}}
|
||||
>
|
||||
{virtualRows.map((virtualRow) => {
|
||||
const row = table.getRowModel().rows[virtualRow.index];
|
||||
if (!row) return null;
|
||||
|
||||
return (
|
||||
<VirtualRow
|
||||
key={virtualRow.key}
|
||||
row={row}
|
||||
virtualRow={{
|
||||
...virtualRow,
|
||||
measureElement: rowVirtualizer.measureElement,
|
||||
}}
|
||||
headerColumns={headerColumns}
|
||||
headerColumnsHash={headerColumnsHash}
|
||||
scrollMargin={rowVirtualizer.options.scrollMargin}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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<HTMLDivElement>(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 (
|
||||
<>
|
||||
<EventsTableToolbar query={query} table={table} />
|
||||
<VirtualizedEventsTable table={table} data={data} isLoading={isLoading} />
|
||||
<div className="w-full h-10 center-center pt-4" ref={inViewportRef}>
|
||||
<div
|
||||
className={cn(
|
||||
'size-8 bg-background rounded-full center-center border opacity-0 transition-opacity',
|
||||
query.isFetchingNextPage && 'opacity-100',
|
||||
)}
|
||||
>
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
function EventsTableToolbar({
|
||||
query,
|
||||
table,
|
||||
}: {
|
||||
query: Props['query'];
|
||||
table: Table<IServiceEvent>;
|
||||
}) {
|
||||
const { projectId } = useAppParams();
|
||||
const [startDate, setStartDate] = useQueryState(
|
||||
@@ -195,6 +304,7 @@ function EventsTableToolbar({
|
||||
parseAsIsoDateTime,
|
||||
);
|
||||
const [endDate, setEndDate] = useQueryState('endDate', parseAsIsoDateTime);
|
||||
|
||||
return (
|
||||
<DataTableToolbarContainer>
|
||||
<div className="flex flex-1 flex-wrap items-center gap-2">
|
||||
@@ -225,74 +335,7 @@ function EventsTableToolbar({
|
||||
/>
|
||||
<OverviewFiltersButtons className="justify-end p-0" />
|
||||
</div>
|
||||
<EventsViewOptions />
|
||||
<DataTableViewOptions table={table} />
|
||||
</DataTableToolbarContainer>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
aria-label="Toggle columns"
|
||||
role="combobox"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="ml-auto hidden h-8 lg:flex"
|
||||
>
|
||||
<Settings2Icon className="size-4 mr-2" />
|
||||
View
|
||||
<ChevronsUpDown className="opacity-50 ml-2 size-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverPortal>
|
||||
<PopoverContent align="end" className="w-44 p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search columns..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No columns found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{Object.entries(columns).map(([column, label]) => (
|
||||
<CommandItem
|
||||
key={column}
|
||||
onSelect={() =>
|
||||
setViewOptions({
|
||||
...viewOptions,
|
||||
// biome-ignore lint/complexity/noUselessTernary: we need this this viewOptions[column] can be undefined
|
||||
[column]: viewOptions[column] === false ? true : false,
|
||||
})
|
||||
}
|
||||
>
|
||||
<span className="truncate">{label}</span>
|
||||
<Check
|
||||
className={cn(
|
||||
'ml-auto size-4 shrink-0',
|
||||
viewOptions[column] !== false
|
||||
? 'opacity-100'
|
||||
: 'opacity-0',
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</PopoverPortal>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ const sellingPoints = [
|
||||
<SellingPoint
|
||||
bgImage="/img-1.png"
|
||||
title="Best open-source alternative"
|
||||
description="Mixpanel to expensive, Google Analytics has no privacy, Amplitude old and boring"
|
||||
description="Mixpanel too expensive, Google Analytics has no privacy, Amplitude old and boring"
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -42,7 +42,7 @@ const onboardingSellingPoints = [
|
||||
<SellingPoint
|
||||
bgImage="/img-1.png"
|
||||
title="Best open-source alternative"
|
||||
description="Mixpanel to expensive, Google Analytics has no privacy, Amplitude old and boring"
|
||||
description="Mixpanel too expensive, Google Analytics has no privacy, Amplitude old and boring"
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -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 (
|
||||
<Wrapper count={0}>
|
||||
<div className="h-full w-full animate-pulse bg-def-200 rounded" />
|
||||
@@ -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 (
|
||||
<Wrapper count={liveCount}>
|
||||
<Wrapper
|
||||
count={totalSessions}
|
||||
icons={
|
||||
<div className="row gap-2">
|
||||
{liveData.referrers.slice(0, 3).map((ref, index) => (
|
||||
<div
|
||||
key={`${ref.referrer}-${ref.count}-${index}`}
|
||||
className="font-bold text-xs row gap-1 items-center"
|
||||
>
|
||||
<SerieIcon name={ref.referrer} />
|
||||
<span>{ref.count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="h-full w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
@@ -119,9 +81,9 @@ export function OverviewLiveHistogram({
|
||||
}}
|
||||
/>
|
||||
<XAxis dataKey="time" axisLine={false} tickLine={false} hide />
|
||||
<YAxis hide />
|
||||
<YAxis hide domain={[0, maxDomain]} />
|
||||
<Bar
|
||||
dataKey="count"
|
||||
dataKey="sessionCount"
|
||||
fill="rgba(59, 121, 255, 0.2)"
|
||||
isAnimationActive={false}
|
||||
shape={BarShapeBlue}
|
||||
@@ -137,13 +99,17 @@ export function OverviewLiveHistogram({
|
||||
interface WrapperProps {
|
||||
children: React.ReactNode;
|
||||
count: number;
|
||||
icons?: React.ReactNode;
|
||||
}
|
||||
|
||||
function Wrapper({ children, count }: WrapperProps) {
|
||||
function Wrapper({ children, count, icons }: WrapperProps) {
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="relative mb-1 text-sm font-medium text-muted-foreground">
|
||||
{count} unique vistors last 30 minutes
|
||||
<div className="row gap-2 justify-between">
|
||||
<div className="relative mb-1 text-sm font-medium text-muted-foreground">
|
||||
{count} sessions last 30 minutes
|
||||
</div>
|
||||
<div>{icons}</div>
|
||||
</div>
|
||||
<div className="relative flex h-full w-full flex-1 items-end justify-center gap-2">
|
||||
{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 = (
|
||||
<div
|
||||
className="flex min-w-[180px] flex-col gap-2 rounded-xl border bg-background/80 p-3 shadow-xl backdrop-blur-sm"
|
||||
className="flex min-w-[220px] flex-col gap-2 rounded-xl border bg-background/80 p-3 shadow-xl backdrop-blur-sm"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top,
|
||||
@@ -221,12 +187,7 @@ const CustomTooltip = ({ active, payload, coordinate }: any) => {
|
||||
}}
|
||||
>
|
||||
<div className="flex justify-between gap-8 text-muted-foreground">
|
||||
<div>
|
||||
{new Date(data.date).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</div>
|
||||
<div>{data.time}</div>
|
||||
</div>
|
||||
<React.Fragment>
|
||||
<div className="flex gap-2">
|
||||
@@ -235,14 +196,43 @@ const CustomTooltip = ({ active, payload, coordinate }: any) => {
|
||||
style={{ background: getChartColor(0) }}
|
||||
/>
|
||||
<div className="col flex-1 gap-1">
|
||||
<div className="flex items-center gap-1">Active users</div>
|
||||
<div className="flex items-center gap-1">Sessions</div>
|
||||
<div className="flex justify-between gap-8 font-mono font-medium">
|
||||
<div className="row gap-1">
|
||||
{number.formatWithUnit(data.count)}
|
||||
{number.formatWithUnit(data.sessionCount)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{data.referrers && data.referrers.length > 0 && (
|
||||
<div className="mt-2 pt-2 border-t border-border">
|
||||
<div className="text-xs text-muted-foreground mb-2">Referrers:</div>
|
||||
<div className="space-y-1">
|
||||
{data.referrers.slice(0, 3).map((ref: any, index: number) => (
|
||||
<div
|
||||
key={`${ref.referrer}-${ref.count}-${index}`}
|
||||
className="row items-center justify-between text-xs"
|
||||
>
|
||||
<div className="row items-center gap-1">
|
||||
<SerieIcon name={ref.referrer} />
|
||||
<span
|
||||
className="truncate max-w-[120px]"
|
||||
title={ref.referrer}
|
||||
>
|
||||
{ref.referrer}
|
||||
</span>
|
||||
</div>
|
||||
<span className="font-mono">{ref.count}</span>
|
||||
</div>
|
||||
))}
|
||||
{data.referrers.length > 3 && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
+{data.referrers.length - 3} more
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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) {
|
||||
</div>
|
||||
|
||||
<div className="card p-4">
|
||||
<div className="text-center mb-3 -mt-1 text-sm font-medium text-muted-foreground">
|
||||
{activeMetric.title}
|
||||
<div className="flex items-center justify-between mb-3 -mt-1">
|
||||
<div className="text-sm font-medium text-muted-foreground">
|
||||
{activeMetric.title}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setChartType('bars')}
|
||||
className={cn(
|
||||
'px-2 py-1 text-xs rounded transition-colors',
|
||||
chartType === 'bars'
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
Bars
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setChartType('lines')}
|
||||
className={cn(
|
||||
'px-2 py-1 text-xs rounded transition-colors',
|
||||
chartType === 'lines'
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
Lines
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full h-[150px]">
|
||||
{overviewQuery.isLoading && <Skeleton className="h-full w-full" />}
|
||||
@@ -141,6 +178,7 @@ export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
|
||||
activeMetric={activeMetric}
|
||||
interval={interval}
|
||||
data={data}
|
||||
chartType={chartType}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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 (
|
||||
<TooltipProvider metric={activeMetric} interval={interval}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={data}>
|
||||
<Customized component={calcStrokeDasharray} />
|
||||
<Line
|
||||
dataKey="calcStrokeDasharray"
|
||||
legendType="none"
|
||||
animationDuration={0}
|
||||
onAnimationEnd={handleAnimationEnd}
|
||||
/>
|
||||
<Tooltip />
|
||||
<YAxis
|
||||
{...yAxisProps}
|
||||
domain={[0, activeMetric.key === 'bounce_rate' ? 100 : 'dataMax']}
|
||||
width={25}
|
||||
/>
|
||||
<XAxis {...xAxisProps} />
|
||||
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
horizontal={true}
|
||||
vertical={false}
|
||||
className="stroke-border"
|
||||
/>
|
||||
|
||||
<Line
|
||||
key={`prev_${activeMetric.key}`}
|
||||
type="linear"
|
||||
dataKey={`prev_${activeMetric.key}`}
|
||||
stroke={'oklch(from var(--foreground) l c h / 0.1)'}
|
||||
strokeWidth={2}
|
||||
isAnimationActive={false}
|
||||
dot={
|
||||
data.length > 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,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Line
|
||||
key={activeMetric.key}
|
||||
type="linear"
|
||||
dataKey={activeMetric.key}
|
||||
stroke={getChartColor(0)}
|
||||
strokeWidth={2}
|
||||
strokeDasharray={
|
||||
useDashedLastLine
|
||||
? getStrokeDasharray(activeMetric.key)
|
||||
: undefined
|
||||
}
|
||||
isAnimationActive={false}
|
||||
dot={
|
||||
data.length > 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,
|
||||
}}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// Bar chart (default)
|
||||
return (
|
||||
<TooltipProvider metric={activeMetric} interval={interval}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
|
||||
@@ -42,7 +42,11 @@ export function OverviewShare({ projectId }: OverviewShareProps) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button icon={data?.public ? Globe2Icon : LockIcon} responsive>
|
||||
<Button
|
||||
icon={data?.public ? Globe2Icon : LockIcon}
|
||||
responsive
|
||||
loading={query.isLoading}
|
||||
>
|
||||
{data?.public ? 'Public' : 'Private'}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
@@ -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<IChartRange>(
|
||||
'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);
|
||||
|
||||
@@ -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<HTMLDivElement>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
if (ref.current && scrollRef.current) {
|
||||
scrollRef.current.style.height = `${ref.current?.getBoundingClientRect().height}px`;
|
||||
}
|
||||
}, [query.data?.data?.length]);
|
||||
|
||||
return (
|
||||
<Widget className="w-full overflow-hidden">
|
||||
<Widget className="w-full overflow-hidden h-full" ref={ref}>
|
||||
<WidgetHead>
|
||||
<WidgetTitle icon={ActivityIcon}>Latest Events</WidgetTitle>
|
||||
<WidgetAbsoluteButtons>
|
||||
<Button variant="outline" size="sm" onClick={handleShowMore}>
|
||||
All
|
||||
</Button>
|
||||
<EventsViewOptions />
|
||||
</WidgetAbsoluteButtons>
|
||||
</WidgetHead>
|
||||
|
||||
<ScrollArea className="h-72">
|
||||
<ScrollArea ref={scrollRef} className="h-0 p-4">
|
||||
{query.data?.data?.map((event) => (
|
||||
<EventItem
|
||||
className="border-0 rounded-none border-b last:border-b-0 [&_[data-slot='inner']]:px-4"
|
||||
key={event.id}
|
||||
event={event}
|
||||
viewOptions={viewOptions}
|
||||
/>
|
||||
<div key={event.id} className="mb-4">
|
||||
<EventListItem {...event} />
|
||||
</div>
|
||||
))}
|
||||
</ScrollArea>
|
||||
</Widget>
|
||||
|
||||
@@ -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<PaginationState>) => {
|
||||
const nextPagination =
|
||||
typeof updaterOrValue === 'function'
|
||||
|
||||
@@ -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<string, boolean>
|
||||
>('@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<string, boolean>,
|
||||
);
|
||||
}, [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<HTMLDivElement>(null);
|
||||
const VirtualizedSessionsTable = ({
|
||||
table,
|
||||
data,
|
||||
isLoading,
|
||||
}: VirtualizedSessionsTableProps) => {
|
||||
const parentRef = useRef<HTMLDivElement>(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 (
|
||||
<div
|
||||
ref={parentRef}
|
||||
className="w-full overflow-x-auto border rounded-md bg-card"
|
||||
>
|
||||
{/* Table Header */}
|
||||
<div
|
||||
ref={parentRef}
|
||||
className="w-full overflow-x-auto border rounded-md bg-card"
|
||||
className="sticky top-0 z-10 bg-card border-b"
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: headerColumns
|
||||
.map((col) => `${col.getSize()}px`)
|
||||
.join(' '),
|
||||
minWidth: 'fit-content',
|
||||
}}
|
||||
>
|
||||
{/* Table Header */}
|
||||
<div
|
||||
className="sticky top-0 z-10 bg-card border-b"
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: headerColumns
|
||||
.map((col) => `${col.getSize()}px`)
|
||||
.join(' '),
|
||||
minWidth: 'fit-content',
|
||||
}}
|
||||
>
|
||||
{headerColumns.map((column) => {
|
||||
const header = column.columnDef.header;
|
||||
const width = `${column.getSize()}px`;
|
||||
return (
|
||||
<div
|
||||
key={column.id}
|
||||
className="flex items-center h-10 px-4 text-left text-[10px] uppercase text-foreground font-semibold whitespace-nowrap"
|
||||
style={{
|
||||
width,
|
||||
}}
|
||||
>
|
||||
{typeof header === 'function' ? header({} as any) : header}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{!isLoading && data.length === 0 && (
|
||||
<FullPageEmptyState
|
||||
title="No sessions found"
|
||||
description="Looks like you haven't inserted any events yet."
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Table Body */}
|
||||
<div
|
||||
className="relative w-full"
|
||||
style={{
|
||||
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||
minHeight: 'fit-content',
|
||||
minWidth: 'fit-content',
|
||||
}}
|
||||
>
|
||||
{virtualRows.map((virtualRow) => {
|
||||
const row = table.getRowModel().rows[virtualRow.index];
|
||||
if (!row) return null;
|
||||
|
||||
return (
|
||||
<VirtualRow
|
||||
key={virtualRow.key}
|
||||
row={row}
|
||||
virtualRow={{
|
||||
...virtualRow,
|
||||
measureElement: rowVirtualizer.measureElement,
|
||||
}}
|
||||
headerColumns={headerColumns}
|
||||
scrollMargin={rowVirtualizer.options.scrollMargin}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{headerColumns.map((column) => {
|
||||
const header = column.columnDef.header;
|
||||
const width = `${column.getSize()}px`;
|
||||
return (
|
||||
<div
|
||||
key={column.id}
|
||||
className="flex items-center h-10 px-4 text-left text-[10px] uppercase text-foreground font-semibold whitespace-nowrap"
|
||||
style={{
|
||||
width,
|
||||
}}
|
||||
>
|
||||
{typeof header === 'function' ? header({} as any) : header}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
arePropsEqual(['data', 'isLoading']),
|
||||
);
|
||||
|
||||
{!isLoading && data.length === 0 && (
|
||||
<FullPageEmptyState
|
||||
title="No sessions found"
|
||||
description="Looks like you haven't inserted any events yet."
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Table Body */}
|
||||
<div
|
||||
className="relative w-full"
|
||||
style={{
|
||||
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||
minHeight: 'fit-content',
|
||||
minWidth: 'fit-content',
|
||||
}}
|
||||
>
|
||||
{virtualRows.map((virtualRow) => {
|
||||
const row = table.getRowModel().rows[virtualRow.index];
|
||||
if (!row) return null;
|
||||
|
||||
return (
|
||||
<VirtualRow
|
||||
key={virtualRow.key}
|
||||
row={row}
|
||||
virtualRow={{
|
||||
...virtualRow,
|
||||
measureElement: rowVirtualizer.measureElement,
|
||||
}}
|
||||
headerColumns={headerColumns}
|
||||
headerColumnsHash={headerColumnsHash}
|
||||
scrollMargin={rowVirtualizer.options.scrollMargin}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = <TData,>(
|
||||
columns: ColumnDef<TData>[],
|
||||
persistentKey: string,
|
||||
) => {
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(
|
||||
const [columnVisibility, setColumnVisibility] = useLocalStorage<
|
||||
Record<string, boolean>
|
||||
>(
|
||||
`@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<string[]>(
|
||||
`@op:${persistentKey}-column-order`,
|
||||
columns.map((column) => column.id!),
|
||||
);
|
||||
|
||||
return { columnVisibility, setColumnVisibility, columnOrder, setColumnOrder };
|
||||
};
|
||||
|
||||
@@ -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<TData> {
|
||||
table: Table<TData>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<CommandItem
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={cn('flex items-center gap-2', isDragging && 'opacity-50')}
|
||||
onSelect={onToggleVisibility}
|
||||
>
|
||||
<div
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="cursor-grab active:cursor-grabbing p-1 hover:bg-muted rounded"
|
||||
>
|
||||
<GripVertical className="size-3 text-muted-foreground" />
|
||||
</div>
|
||||
<span className="truncate flex-1">
|
||||
{typeof column.columnDef.header === 'string'
|
||||
? column.columnDef.header
|
||||
: (column.columnDef.meta?.label ?? column.id)}
|
||||
</span>
|
||||
<Check
|
||||
className={cn(
|
||||
'ml-auto size-4 shrink-0',
|
||||
column.getIsVisible() ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
);
|
||||
}
|
||||
|
||||
export function DataTableViewOptions<TData>({
|
||||
table,
|
||||
}: DataTableViewOptionsProps<TData>) {
|
||||
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 (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
@@ -50,32 +184,41 @@ export function DataTableViewOptions<TData>({
|
||||
<ChevronsUpDown className="opacity-50 ml-2 size-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-44 p-0">
|
||||
<PopoverContent align="end" className="w-52 p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search columns..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No columns found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{columns.map((column) => (
|
||||
<CommandItem
|
||||
key={column.id}
|
||||
onSelect={() =>
|
||||
column.toggleVisibility(!column.getIsVisible())
|
||||
}
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={columns.map((col) => col.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<span className="truncate">
|
||||
{typeof column.columnDef.header === 'string'
|
||||
? column.columnDef.header
|
||||
: (column.columnDef.meta?.label ?? column.id)}
|
||||
</span>
|
||||
<Check
|
||||
className={cn(
|
||||
'ml-auto size-4 shrink-0',
|
||||
column.getIsVisible() ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
))}
|
||||
{columns.map((column) => (
|
||||
<SortableColumnItem
|
||||
key={column.id}
|
||||
column={column}
|
||||
onToggleVisibility={() =>
|
||||
column.toggleVisibility(!column.getIsVisible())
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</CommandGroup>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
onSelect={handleReset}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
<RotateCcw className="size-4 mr-2" />
|
||||
Reset to default
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
|
||||
@@ -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<TData>({
|
||||
pageSize,
|
||||
data,
|
||||
loading,
|
||||
name,
|
||||
}: {
|
||||
columns: ColumnDef<TData>[];
|
||||
pageSize: number;
|
||||
data: TData[];
|
||||
loading: boolean;
|
||||
name: string;
|
||||
}) {
|
||||
const [page, setPage] = useQueryState(
|
||||
'page',
|
||||
@@ -54,6 +57,9 @@ export function useTable<TData>({
|
||||
pageSize: perPage,
|
||||
};
|
||||
|
||||
const { columnVisibility, setColumnVisibility, columnOrder, setColumnOrder } =
|
||||
useDataTableColumnVisibility(columns, name);
|
||||
|
||||
const [columnPinning, setColumnPinning] = useState<ColumnPinningState>({
|
||||
left: [
|
||||
...columns
|
||||
@@ -149,6 +155,9 @@ export function useTable<TData>({
|
||||
};
|
||||
|
||||
const table = useReactTable({
|
||||
meta: {
|
||||
name,
|
||||
},
|
||||
columns,
|
||||
data: useMemo(
|
||||
() =>
|
||||
@@ -181,7 +190,11 @@ export function useTable<TData>({
|
||||
pagination,
|
||||
columnPinning,
|
||||
columnFilters: loading ? [] : columnFilters,
|
||||
columnOrder,
|
||||
columnVisibility,
|
||||
},
|
||||
onColumnOrderChange: setColumnOrder,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onColumnPinningChange: setColumnPinning,
|
||||
onColumnFiltersChange: (updaterOrValue: Updater<ColumnFiltersState>) => {
|
||||
setColumnFilters((prev) => {
|
||||
|
||||
@@ -57,7 +57,12 @@ export function WidgetBody({ children, className }: WidgetBodyProps) {
|
||||
export interface WidgetProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
ref?: React.RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
export function Widget({ children, className }: WidgetProps) {
|
||||
return <div className={cn('card self-start', className)}>{children}</div>;
|
||||
export function Widget({ children, className, ...props }: WidgetProps) {
|
||||
return (
|
||||
<div className={cn('card self-start', className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
35
apps/start/src/hooks/use-cookie-store.tsx
Normal file
35
apps/start/src/hooks/use-cookie-store.tsx
Normal file
@@ -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<T>(key: string, defaultValue: T) {
|
||||
const { cookies } = useRouteContext({ strict: false });
|
||||
const [value, setValue] = useState<T>((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],
|
||||
);
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<MyRouterContext>()({
|
||||
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: [
|
||||
|
||||
@@ -59,13 +59,15 @@ function Component() {
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title={
|
||||
<div className="row items-center gap-4">
|
||||
<div className="row items-center gap-4 min-w-0">
|
||||
<ProfileAvatar {...profile.data} />
|
||||
{getProfileName(profile.data, false)}
|
||||
<span className="truncate">
|
||||
{getProfileName(profile.data, false)}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="row gap-4 mb-6">
|
||||
<div className="row gap-4 mb-6 flex-wrap">
|
||||
{profile.data?.properties.country && (
|
||||
<div className="row gap-2 items-center">
|
||||
<SerieIcon name={profile.data.properties.country} />
|
||||
|
||||
@@ -163,6 +163,7 @@ function Component() {
|
||||
const data = query.data ?? [];
|
||||
|
||||
const { table, loading } = useTable({
|
||||
name: 'references',
|
||||
columns: columnDefs,
|
||||
data,
|
||||
pageSize: 30,
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
const prefix = '@op';
|
||||
|
||||
export function getStorageItem<T>(key: string): T | null;
|
||||
export function getStorageItem<T>(key: string, defaultValue: T): T;
|
||||
export function getStorageItem<T>(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}`);
|
||||
}
|
||||
@@ -247,11 +247,16 @@ export class Query<T = any> {
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user