fix: improvements in the dashboard
This commit is contained in:
@@ -18,6 +18,7 @@ export const ClientsTable = ({ query }: Props) => {
|
|||||||
const { data, isLoading } = query;
|
const { data, isLoading } = query;
|
||||||
|
|
||||||
const { table } = useTable({
|
const { table } = useTable({
|
||||||
|
name: 'clients',
|
||||||
columns,
|
columns,
|
||||||
data: data ?? [],
|
data: data ?? [],
|
||||||
loading: isLoading,
|
loading: isLoading,
|
||||||
|
|||||||
@@ -3,16 +3,34 @@ import { ProjectLink } from '@/components/links';
|
|||||||
import { SerieIcon } from '@/components/report-chart/common/serie-icon';
|
import { SerieIcon } from '@/components/report-chart/common/serie-icon';
|
||||||
import { useNumber } from '@/hooks/use-numer-formatter';
|
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||||
import { pushModal } from '@/modals';
|
import { pushModal } from '@/modals';
|
||||||
import { formatDateTime, formatTime } from '@/utils/date';
|
import { formatDateTime, formatTimeAgoOrDateTime } from '@/utils/date';
|
||||||
import { getProfileName } from '@/utils/getters';
|
import { getProfileName } from '@/utils/getters';
|
||||||
import type { ColumnDef } from '@tanstack/react-table';
|
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';
|
import type { IServiceEvent } from '@openpanel/db';
|
||||||
|
|
||||||
export function useColumns() {
|
export function useColumns() {
|
||||||
const number = useNumber();
|
const number = useNumber();
|
||||||
const columns: ColumnDef<IServiceEvent>[] = [
|
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,
|
size: 300,
|
||||||
accessorKey: 'name',
|
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',
|
accessorKey: 'profileId',
|
||||||
header: 'Profile',
|
header: 'Profile',
|
||||||
@@ -141,11 +148,17 @@ export function useColumns() {
|
|||||||
accessorKey: 'sessionId',
|
accessorKey: 'sessionId',
|
||||||
header: 'Session ID',
|
header: 'Session ID',
|
||||||
size: 320,
|
size: 320,
|
||||||
|
meta: {
|
||||||
|
hidden: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'deviceId',
|
accessorKey: 'deviceId',
|
||||||
header: 'Device ID',
|
header: 'Device ID',
|
||||||
size: 320,
|
size: 320,
|
||||||
|
meta: {
|
||||||
|
hidden: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'country',
|
accessorKey: 'country',
|
||||||
@@ -193,6 +206,9 @@ export function useColumns() {
|
|||||||
accessorKey: 'properties',
|
accessorKey: 'properties',
|
||||||
header: 'Properties',
|
header: 'Properties',
|
||||||
size: 400,
|
size: 400,
|
||||||
|
meta: {
|
||||||
|
hidden: true,
|
||||||
|
},
|
||||||
cell({ row }) {
|
cell({ row }) {
|
||||||
const { properties } = row.original;
|
const { properties } = row.original;
|
||||||
const filteredProperties = Object.fromEntries(
|
const filteredProperties = Object.fromEntries(
|
||||||
@@ -201,19 +217,23 @@ export function useColumns() {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
const items = Object.entries(filteredProperties);
|
const items = Object.entries(filteredProperties);
|
||||||
return (
|
const limit = 1;
|
||||||
<div className="row flex-wrap gap-x-4 gap-y-1 overflow-hidden text-sm">
|
const data = items.slice(0, limit).map(([key, value]) => ({
|
||||||
{items.slice(0, 4).map(([key, value]) => (
|
name: key,
|
||||||
<div key={key} className="row items-center gap-1 min-w-0">
|
value: value,
|
||||||
<span className="text-muted-foreground">{key}</span>
|
}));
|
||||||
<span className="truncate font-medium">{String(value)}</span>
|
if (items.length > limit) {
|
||||||
</div>
|
data.push({
|
||||||
))}
|
name: '',
|
||||||
{items.length > 5 && (
|
value: `${items.length - limit} more item${items.length - limit === 1 ? '' : 's'}`,
|
||||||
<span className="truncate">{items.length - 5} more</span>
|
});
|
||||||
)}
|
}
|
||||||
</div>
|
|
||||||
);
|
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 { FullPageEmptyState } from '@/components/full-page-empty-state';
|
||||||
import { OverviewFiltersButtons } from '@/components/overview/filters/overview-filters-buttons';
|
import { OverviewFiltersButtons } from '@/components/overview/filters/overview-filters-buttons';
|
||||||
import { OverviewFiltersDrawer } from '@/components/overview/filters/overview-filters-drawer';
|
import { OverviewFiltersDrawer } from '@/components/overview/filters/overview-filters-drawer';
|
||||||
|
import { Skeleton } from '@/components/skeleton';
|
||||||
import { Button } from '@/components/ui/button';
|
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 { 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 { useAppParams } from '@/hooks/use-app-params';
|
||||||
import { pushModal } from '@/modals';
|
import { pushModal } from '@/modals';
|
||||||
import type { RouterInputs, RouterOutputs } from '@/trpc/client';
|
import type { RouterInputs, RouterOutputs } from '@/trpc/client';
|
||||||
import { arePropsEqual } from '@/utils/are-props-equal';
|
import { arePropsEqual } from '@/utils/are-props-equal';
|
||||||
import { cn } from '@/utils/cn';
|
import { cn } from '@/utils/cn';
|
||||||
|
import type { IServiceEvent } from '@openpanel/db';
|
||||||
import type { UseInfiniteQueryResult } from '@tanstack/react-query';
|
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 { useWindowVirtualizer } from '@tanstack/react-virtual';
|
||||||
import type { TRPCInfiniteData } from '@trpc/tanstack-react-query';
|
import type { TRPCInfiniteData } from '@trpc/tanstack-react-query';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
@@ -32,20 +24,11 @@ import throttle from 'lodash.throttle';
|
|||||||
import { CalendarIcon, Loader2Icon } from 'lucide-react';
|
import { CalendarIcon, Loader2Icon } from 'lucide-react';
|
||||||
import { parseAsIsoDateTime, useQueryState } from 'nuqs';
|
import { parseAsIsoDateTime, useQueryState } from 'nuqs';
|
||||||
import { last } from 'ramda';
|
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 { useInViewport } from 'react-in-viewport';
|
||||||
import { useLocalStorage } from 'usehooks-ts';
|
import { useLocalStorage } from 'usehooks-ts';
|
||||||
import EventListener from '../event-listener';
|
import EventListener from '../event-listener';
|
||||||
import { EventItem, EventItemSkeleton } from './item';
|
import { useColumns } from './columns';
|
||||||
|
|
||||||
export const useEventsViewOptions = () => {
|
|
||||||
return useLocalStorage<Record<string, boolean | undefined>>(
|
|
||||||
'@op:events-table-view-options',
|
|
||||||
{
|
|
||||||
properties: false,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
query: UseInfiniteQueryResult<
|
query: UseInfiniteQueryResult<
|
||||||
@@ -57,137 +40,263 @@ type Props = {
|
|||||||
>;
|
>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const EventsTable = memo(
|
const LOADING_DATA = [{}, {}, {}, {}, {}, {}, {}, {}, {}] as IServiceEvent[];
|
||||||
({ query }: Props) => {
|
const ROW_HEIGHT = 40;
|
||||||
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 data = query.data?.pages?.flatMap((p) => p.data) ?? [];
|
interface VirtualizedEventsTableProps {
|
||||||
|
table: Table<IServiceEvent>;
|
||||||
|
data: IServiceEvent[];
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
const virtualizer = useWindowVirtualizer({
|
interface VirtualRowProps {
|
||||||
count: data.length,
|
row: any;
|
||||||
estimateSize: () => 55,
|
virtualRow: any;
|
||||||
scrollMargin,
|
headerColumns: any[];
|
||||||
overscan: 10,
|
scrollMargin: number;
|
||||||
});
|
isLoading: boolean;
|
||||||
|
headerColumnsHash: string;
|
||||||
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();
|
|
||||||
|
|
||||||
|
const VirtualRow = memo(
|
||||||
|
function VirtualRow({
|
||||||
|
row,
|
||||||
|
virtualRow,
|
||||||
|
headerColumns,
|
||||||
|
scrollMargin,
|
||||||
|
isLoading,
|
||||||
|
}: VirtualRowProps) {
|
||||||
return (
|
return (
|
||||||
<>
|
<div
|
||||||
<EventsTableToolbar query={query} />
|
key={virtualRow.key}
|
||||||
<div ref={parentRef} className="w-full">
|
data-index={virtualRow.index}
|
||||||
{isLoading && (
|
ref={virtualRow.measureElement}
|
||||||
<div className="w-full gap-2 col">
|
className="absolute top-0 left-0 w-full border-b hover:bg-muted/50 transition-colors group/row"
|
||||||
<EventItemSkeleton />
|
style={{
|
||||||
<EventItemSkeleton />
|
transform: `translateY(${virtualRow.start - scrollMargin}px)`,
|
||||||
<EventItemSkeleton />
|
display: 'grid',
|
||||||
<EventItemSkeleton />
|
gridTemplateColumns: headerColumns
|
||||||
<EventItemSkeleton />
|
.map((col) => `${col.getSize()}px`)
|
||||||
<EventItemSkeleton />
|
.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>
|
</div>
|
||||||
)}
|
);
|
||||||
{!isLoading && data.length === 0 && (
|
})}
|
||||||
<FullPageEmptyState
|
</div>
|
||||||
title="No events"
|
);
|
||||||
description={"Start sending events and you'll see them here"}
|
},
|
||||||
/>
|
(prevProps, nextProps) => {
|
||||||
)}
|
return (
|
||||||
<div
|
prevProps.row.id === nextProps.row.id &&
|
||||||
style={{
|
prevProps.virtualRow.index === nextProps.virtualRow.index &&
|
||||||
height: `${virtualizer.getTotalSize()}px`,
|
prevProps.virtualRow.start === nextProps.virtualRow.start &&
|
||||||
width: '100%',
|
prevProps.virtualRow.size === nextProps.virtualRow.size &&
|
||||||
position: 'relative',
|
prevProps.isLoading === nextProps.isLoading &&
|
||||||
}}
|
prevProps.headerColumnsHash === nextProps.headerColumnsHash
|
||||||
>
|
|
||||||
{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>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
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({
|
function EventsTableToolbar({
|
||||||
query,
|
query,
|
||||||
|
table,
|
||||||
}: {
|
}: {
|
||||||
query: Props['query'];
|
query: Props['query'];
|
||||||
|
table: Table<IServiceEvent>;
|
||||||
}) {
|
}) {
|
||||||
const { projectId } = useAppParams();
|
const { projectId } = useAppParams();
|
||||||
const [startDate, setStartDate] = useQueryState(
|
const [startDate, setStartDate] = useQueryState(
|
||||||
@@ -195,6 +304,7 @@ function EventsTableToolbar({
|
|||||||
parseAsIsoDateTime,
|
parseAsIsoDateTime,
|
||||||
);
|
);
|
||||||
const [endDate, setEndDate] = useQueryState('endDate', parseAsIsoDateTime);
|
const [endDate, setEndDate] = useQueryState('endDate', parseAsIsoDateTime);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DataTableToolbarContainer>
|
<DataTableToolbarContainer>
|
||||||
<div className="flex flex-1 flex-wrap items-center gap-2">
|
<div className="flex flex-1 flex-wrap items-center gap-2">
|
||||||
@@ -225,74 +335,7 @@ function EventsTableToolbar({
|
|||||||
/>
|
/>
|
||||||
<OverviewFiltersButtons className="justify-end p-0" />
|
<OverviewFiltersButtons className="justify-end p-0" />
|
||||||
</div>
|
</div>
|
||||||
<EventsViewOptions />
|
<DataTableViewOptions table={table} />
|
||||||
</DataTableToolbarContainer>
|
</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
|
<SellingPoint
|
||||||
bgImage="/img-1.png"
|
bgImage="/img-1.png"
|
||||||
title="Best open-source alternative"
|
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 columns = useColumns();
|
||||||
const { data, isLoading } = query;
|
const { data, isLoading } = query;
|
||||||
const { table } = useTable({
|
const { table } = useTable({
|
||||||
|
name: 'notifications',
|
||||||
columns,
|
columns,
|
||||||
data: data ?? [],
|
data: data ?? [],
|
||||||
loading: isLoading,
|
loading: isLoading,
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ const onboardingSellingPoints = [
|
|||||||
<SellingPoint
|
<SellingPoint
|
||||||
bgImage="/img-1.png"
|
bgImage="/img-1.png"
|
||||||
title="Best open-source alternative"
|
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 { cn } from '@/utils/cn';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
import type { IChartProps } from '@openpanel/validation';
|
|
||||||
|
|
||||||
import { useNumber } from '@/hooks/use-numer-formatter';
|
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||||
import { getChartColor } from '@/utils/theme';
|
import { getChartColor } from '@/utils/theme';
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
@@ -19,6 +17,7 @@ import {
|
|||||||
YAxis,
|
YAxis,
|
||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
import { BarShapeBlue } from '../charts/common-bar';
|
import { BarShapeBlue } from '../charts/common-bar';
|
||||||
|
import { SerieIcon } from '../report-chart/common/serie-icon';
|
||||||
|
|
||||||
interface OverviewLiveHistogramProps {
|
interface OverviewLiveHistogramProps {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@@ -27,72 +26,17 @@ interface OverviewLiveHistogramProps {
|
|||||||
export function OverviewLiveHistogram({
|
export function OverviewLiveHistogram({
|
||||||
projectId,
|
projectId,
|
||||||
}: OverviewLiveHistogramProps) {
|
}: 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 trpc = useTRPC();
|
||||||
|
|
||||||
const res = useQuery(trpc.chart.chart.queryOptions(report));
|
// Use the new liveData endpoint instead of chart props
|
||||||
const countRes = useQuery(trpc.chart.chart.queryOptions(countReport));
|
const { data: liveData, isLoading } = useQuery(
|
||||||
|
trpc.overview.liveData.queryOptions({ projectId }),
|
||||||
|
);
|
||||||
|
|
||||||
const metrics = res.data?.series[0]?.metrics;
|
const totalSessions = liveData?.totalSessions ?? 0;
|
||||||
const minutes = (res.data?.series[0]?.data || []).slice(-30);
|
const chartData = liveData?.minuteCounts ?? [];
|
||||||
const liveCount = countRes.data?.series[0]?.metrics?.sum ?? 0;
|
|
||||||
|
|
||||||
// Transform data for Recharts
|
if (isLoading) {
|
||||||
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) {
|
|
||||||
return (
|
return (
|
||||||
<Wrapper count={0}>
|
<Wrapper count={0}>
|
||||||
<div className="h-full w-full animate-pulse bg-def-200 rounded" />
|
<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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const maxDomain =
|
||||||
|
Math.max(...chartData.map((item) => item.sessionCount)) * 1.2;
|
||||||
|
|
||||||
return (
|
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">
|
<div className="h-full w-full">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<BarChart
|
<BarChart
|
||||||
@@ -119,9 +81,9 @@ export function OverviewLiveHistogram({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<XAxis dataKey="time" axisLine={false} tickLine={false} hide />
|
<XAxis dataKey="time" axisLine={false} tickLine={false} hide />
|
||||||
<YAxis hide />
|
<YAxis hide domain={[0, maxDomain]} />
|
||||||
<Bar
|
<Bar
|
||||||
dataKey="count"
|
dataKey="sessionCount"
|
||||||
fill="rgba(59, 121, 255, 0.2)"
|
fill="rgba(59, 121, 255, 0.2)"
|
||||||
isAnimationActive={false}
|
isAnimationActive={false}
|
||||||
shape={BarShapeBlue}
|
shape={BarShapeBlue}
|
||||||
@@ -137,13 +99,17 @@ export function OverviewLiveHistogram({
|
|||||||
interface WrapperProps {
|
interface WrapperProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
count: number;
|
count: number;
|
||||||
|
icons?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Wrapper({ children, count }: WrapperProps) {
|
function Wrapper({ children, count, icons }: WrapperProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
<div className="relative mb-1 text-sm font-medium text-muted-foreground">
|
<div className="row gap-2 justify-between">
|
||||||
{count} unique vistors last 30 minutes
|
<div className="relative mb-1 text-sm font-medium text-muted-foreground">
|
||||||
|
{count} sessions last 30 minutes
|
||||||
|
</div>
|
||||||
|
<div>{icons}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative flex h-full w-full flex-1 items-end justify-center gap-2">
|
<div className="relative flex h-full w-full flex-1 items-end justify-center gap-2">
|
||||||
{children}
|
{children}
|
||||||
@@ -182,8 +148,8 @@ const CustomTooltip = ({ active, payload, coordinate }: any) => {
|
|||||||
const data = payload[0].payload;
|
const data = payload[0].payload;
|
||||||
|
|
||||||
// Smart positioning to avoid going out of bounds
|
// Smart positioning to avoid going out of bounds
|
||||||
const tooltipWidth = 180; // min-w-[180px]
|
const tooltipWidth = 220; // min-w-[220px] to accommodate referrers
|
||||||
const tooltipHeight = 80; // approximate height
|
const tooltipHeight = 120; // approximate height with referrers
|
||||||
const offset = 10;
|
const offset = 10;
|
||||||
|
|
||||||
let left = mousePosition.x + offset;
|
let left = mousePosition.x + offset;
|
||||||
@@ -211,7 +177,7 @@ const CustomTooltip = ({ active, payload, coordinate }: any) => {
|
|||||||
|
|
||||||
const tooltipContent = (
|
const tooltipContent = (
|
||||||
<div
|
<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={{
|
style={{
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
top,
|
top,
|
||||||
@@ -221,12 +187,7 @@ const CustomTooltip = ({ active, payload, coordinate }: any) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex justify-between gap-8 text-muted-foreground">
|
<div className="flex justify-between gap-8 text-muted-foreground">
|
||||||
<div>
|
<div>{data.time}</div>
|
||||||
{new Date(data.date).toLocaleTimeString([], {
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -235,14 +196,43 @@ const CustomTooltip = ({ active, payload, coordinate }: any) => {
|
|||||||
style={{ background: getChartColor(0) }}
|
style={{ background: getChartColor(0) }}
|
||||||
/>
|
/>
|
||||||
<div className="col flex-1 gap-1">
|
<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="flex justify-between gap-8 font-mono font-medium">
|
||||||
<div className="row gap-1">
|
<div className="row gap-1">
|
||||||
{number.formatWithUnit(data.count)}
|
{number.formatWithUnit(data.sessionCount)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</React.Fragment>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
|||||||
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
|
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
|
||||||
import { cn } from '@/utils/cn';
|
import { cn } from '@/utils/cn';
|
||||||
|
|
||||||
|
import { useCookieStore } from '@/hooks/use-cookie-store';
|
||||||
import { useDashedStroke } from '@/hooks/use-dashed-stroke';
|
import { useDashedStroke } from '@/hooks/use-dashed-stroke';
|
||||||
import { useFormatDateInterval } from '@/hooks/use-format-date-interval';
|
import { useFormatDateInterval } from '@/hooks/use-format-date-interval';
|
||||||
import { useNumber } from '@/hooks/use-numer-formatter';
|
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||||
@@ -18,11 +19,14 @@ import {
|
|||||||
Bar,
|
Bar,
|
||||||
CartesianGrid,
|
CartesianGrid,
|
||||||
ComposedChart,
|
ComposedChart,
|
||||||
|
Customized,
|
||||||
Line,
|
Line,
|
||||||
|
LineChart,
|
||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
XAxis,
|
XAxis,
|
||||||
YAxis,
|
YAxis,
|
||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
|
import { useLocalStorage } from 'usehooks-ts';
|
||||||
import { createChartTooltip } from '../charts/chart-tooltip';
|
import { createChartTooltip } from '../charts/chart-tooltip';
|
||||||
import { BarShapeBlue, BarShapeGrey } from '../charts/common-bar';
|
import { BarShapeBlue, BarShapeGrey } from '../charts/common-bar';
|
||||||
import { useXAxisProps, useYAxisProps } from '../report-chart/common/axis';
|
import { useXAxisProps, useYAxisProps } from '../report-chart/common/axis';
|
||||||
@@ -80,6 +84,11 @@ export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
|
|||||||
const [filters] = useEventQueryFilters();
|
const [filters] = useEventQueryFilters();
|
||||||
const trpc = useTRPC();
|
const trpc = useTRPC();
|
||||||
|
|
||||||
|
const [chartType, setChartType] = useCookieStore<'bars' | 'lines'>(
|
||||||
|
'chartType',
|
||||||
|
'bars',
|
||||||
|
);
|
||||||
|
|
||||||
const activeMetric = TITLES[metric]!;
|
const activeMetric = TITLES[metric]!;
|
||||||
const overviewQuery = useQuery(
|
const overviewQuery = useQuery(
|
||||||
trpc.overview.stats.queryOptions({
|
trpc.overview.stats.queryOptions({
|
||||||
@@ -132,8 +141,36 @@ export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card p-4">
|
<div className="card p-4">
|
||||||
<div className="text-center mb-3 -mt-1 text-sm font-medium text-muted-foreground">
|
<div className="flex items-center justify-between mb-3 -mt-1">
|
||||||
{activeMetric.title}
|
<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>
|
||||||
<div className="w-full h-[150px]">
|
<div className="w-full h-[150px]">
|
||||||
{overviewQuery.isLoading && <Skeleton className="h-full w-full" />}
|
{overviewQuery.isLoading && <Skeleton className="h-full w-full" />}
|
||||||
@@ -141,6 +178,7 @@ export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
|
|||||||
activeMetric={activeMetric}
|
activeMetric={activeMetric}
|
||||||
interval={interval}
|
interval={interval}
|
||||||
data={data}
|
data={data}
|
||||||
|
chartType={chartType}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -205,15 +243,142 @@ function Chart({
|
|||||||
activeMetric,
|
activeMetric,
|
||||||
interval,
|
interval,
|
||||||
data,
|
data,
|
||||||
|
chartType,
|
||||||
}: {
|
}: {
|
||||||
activeMetric: (typeof TITLES)[number];
|
activeMetric: (typeof TITLES)[number];
|
||||||
interval: IInterval;
|
interval: IInterval;
|
||||||
data: RouterOutputs['overview']['stats']['series'];
|
data: RouterOutputs['overview']['stats']['series'];
|
||||||
|
chartType: 'bars' | 'lines';
|
||||||
}) {
|
}) {
|
||||||
const xAxisProps = useXAxisProps({ interval });
|
const xAxisProps = useXAxisProps({ interval });
|
||||||
const yAxisProps = useYAxisProps();
|
const yAxisProps = useYAxisProps();
|
||||||
const [activeBar, setActiveBar] = useState(-1);
|
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 (
|
return (
|
||||||
<TooltipProvider metric={activeMetric} interval={interval}>
|
<TooltipProvider metric={activeMetric} interval={interval}>
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
|||||||
@@ -42,7 +42,11 @@ export function OverviewShare({ projectId }: OverviewShareProps) {
|
|||||||
return (
|
return (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button icon={data?.public ? Globe2Icon : LockIcon} responsive>
|
<Button
|
||||||
|
icon={data?.public ? Globe2Icon : LockIcon}
|
||||||
|
responsive
|
||||||
|
loading={query.isLoading}
|
||||||
|
>
|
||||||
{data?.public ? 'Public' : 'Private'}
|
{data?.public ? 'Public' : 'Private'}
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
useQueryState,
|
useQueryState,
|
||||||
} from 'nuqs';
|
} from 'nuqs';
|
||||||
|
|
||||||
import { getStorageItem, setStorageItem } from '@/utils/storage';
|
import { useCookieStore } from '@/hooks/use-cookie-store';
|
||||||
import {
|
import {
|
||||||
getDefaultIntervalByDates,
|
getDefaultIntervalByDates,
|
||||||
getDefaultIntervalByRange,
|
getDefaultIntervalByRange,
|
||||||
@@ -27,10 +27,14 @@ export function useOverviewOptions() {
|
|||||||
'end',
|
'end',
|
||||||
parseAsString.withOptions(nuqsOptions),
|
parseAsString.withOptions(nuqsOptions),
|
||||||
);
|
);
|
||||||
|
const [cookieRange, setCookieRange] = useCookieStore<IChartRange>(
|
||||||
|
'range',
|
||||||
|
'7d',
|
||||||
|
);
|
||||||
const [range, setRange] = useQueryState(
|
const [range, setRange] = useQueryState(
|
||||||
'range',
|
'range',
|
||||||
parseAsStringEnum(mapKeys(timeWindows))
|
parseAsStringEnum(mapKeys(timeWindows))
|
||||||
.withDefault(getStorageItem('range', '7d'))
|
.withDefault(cookieRange)
|
||||||
.withOptions({
|
.withOptions({
|
||||||
...nuqsOptions,
|
...nuqsOptions,
|
||||||
clearOnDefault: false,
|
clearOnDefault: false,
|
||||||
@@ -69,7 +73,9 @@ export function useOverviewOptions() {
|
|||||||
if (value !== 'custom') {
|
if (value !== 'custom') {
|
||||||
setStartDate(null);
|
setStartDate(null);
|
||||||
setEndDate(null);
|
setEndDate(null);
|
||||||
setStorageItem('range', value);
|
if (value) {
|
||||||
|
setCookieRange(value);
|
||||||
|
}
|
||||||
setInterval(null);
|
setInterval(null);
|
||||||
}
|
}
|
||||||
setRange(value);
|
setRange(value);
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import { useTRPC } from '@/integrations/trpc/react';
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useRouter } from '@tanstack/react-router';
|
import { useRouter } from '@tanstack/react-router';
|
||||||
import { ActivityIcon } from 'lucide-react';
|
import { ActivityIcon } from 'lucide-react';
|
||||||
import { EventsViewOptions, useEventsViewOptions } from '../events/table';
|
import { useEffect, useRef } from 'react';
|
||||||
import { EventItem } from '../events/table/item';
|
import { EventListItem } from '../events/event-list-item';
|
||||||
import {
|
import {
|
||||||
WidgetAbsoluteButtons,
|
WidgetAbsoluteButtons,
|
||||||
WidgetHead,
|
WidgetHead,
|
||||||
@@ -24,7 +24,6 @@ export const LatestEvents = ({
|
|||||||
projectId,
|
projectId,
|
||||||
organizationId,
|
organizationId,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const [viewOptions] = useEventsViewOptions();
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const trpc = useTRPC();
|
const trpc = useTRPC();
|
||||||
const query = useQuery(
|
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 (
|
return (
|
||||||
<Widget className="w-full overflow-hidden">
|
<Widget className="w-full overflow-hidden h-full" ref={ref}>
|
||||||
<WidgetHead>
|
<WidgetHead>
|
||||||
<WidgetTitle icon={ActivityIcon}>Latest Events</WidgetTitle>
|
<WidgetTitle icon={ActivityIcon}>Latest Events</WidgetTitle>
|
||||||
<WidgetAbsoluteButtons>
|
<WidgetAbsoluteButtons>
|
||||||
<Button variant="outline" size="sm" onClick={handleShowMore}>
|
<Button variant="outline" size="sm" onClick={handleShowMore}>
|
||||||
All
|
All
|
||||||
</Button>
|
</Button>
|
||||||
<EventsViewOptions />
|
|
||||||
</WidgetAbsoluteButtons>
|
</WidgetAbsoluteButtons>
|
||||||
</WidgetHead>
|
</WidgetHead>
|
||||||
|
|
||||||
<ScrollArea className="h-72">
|
<ScrollArea ref={scrollRef} className="h-0 p-4">
|
||||||
{query.data?.data?.map((event) => (
|
{query.data?.data?.map((event) => (
|
||||||
<EventItem
|
<div key={event.id} className="mb-4">
|
||||||
className="border-0 rounded-none border-b last:border-b-0 [&_[data-slot='inner']]:px-4"
|
<EventListItem {...event} />
|
||||||
key={event.id}
|
</div>
|
||||||
event={event}
|
|
||||||
viewOptions={viewOptions}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</Widget>
|
</Widget>
|
||||||
|
|||||||
@@ -31,8 +31,12 @@ export const ProfilesTable = memo(
|
|||||||
const columns = useColumns(type);
|
const columns = useColumns(type);
|
||||||
|
|
||||||
const { setPage, state: pagination } = useDataTablePagination();
|
const { setPage, state: pagination } = useDataTablePagination();
|
||||||
const { columnVisibility, setColumnVisibility } =
|
const {
|
||||||
useDataTableColumnVisibility(columns);
|
columnVisibility,
|
||||||
|
setColumnVisibility,
|
||||||
|
columnOrder,
|
||||||
|
setColumnOrder,
|
||||||
|
} = useDataTableColumnVisibility(columns, 'profiles');
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data: isLoading ? LOADING_DATA : (data?.data ?? []),
|
data: isLoading ? LOADING_DATA : (data?.data ?? []),
|
||||||
@@ -51,8 +55,10 @@ export const ProfilesTable = memo(
|
|||||||
state: {
|
state: {
|
||||||
pagination,
|
pagination,
|
||||||
columnVisibility,
|
columnVisibility,
|
||||||
|
columnOrder,
|
||||||
},
|
},
|
||||||
onColumnVisibilityChange: setColumnVisibility,
|
onColumnVisibilityChange: setColumnVisibility,
|
||||||
|
onColumnOrderChange: setColumnOrder,
|
||||||
onPaginationChange: (updaterOrValue: Updater<PaginationState>) => {
|
onPaginationChange: (updaterOrValue: Updater<PaginationState>) => {
|
||||||
const nextPagination =
|
const nextPagination =
|
||||||
typeof updaterOrValue === 'function'
|
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 { useDataTableColumnVisibility } from '@/components/ui/data-table/data-table-hooks';
|
||||||
import type { RouterInputs, RouterOutputs } from '@/trpc/client';
|
import type { RouterInputs, RouterOutputs } from '@/trpc/client';
|
||||||
|
import { useLocalStorage } from 'usehooks-ts';
|
||||||
import { useColumns } from './columns';
|
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 { FullPageEmptyState } from '@/components/full-page-empty-state';
|
||||||
import { Skeleton } from '@/components/skeleton';
|
import { Skeleton } from '@/components/skeleton';
|
||||||
import {
|
import {
|
||||||
@@ -21,7 +54,7 @@ import { useWindowVirtualizer } from '@tanstack/react-virtual';
|
|||||||
import type { TRPCInfiniteData } from '@trpc/tanstack-react-query';
|
import type { TRPCInfiniteData } from '@trpc/tanstack-react-query';
|
||||||
import { Loader2Icon } from 'lucide-react';
|
import { Loader2Icon } from 'lucide-react';
|
||||||
import { last } from 'ramda';
|
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';
|
import { useInViewport } from 'react-in-viewport';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -49,6 +82,7 @@ interface VirtualRowProps {
|
|||||||
headerColumns: any[];
|
headerColumns: any[];
|
||||||
scrollMargin: number;
|
scrollMargin: number;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
|
headerColumnsHash: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const VirtualRow = memo(
|
const VirtualRow = memo(
|
||||||
@@ -109,109 +143,105 @@ const VirtualRow = memo(
|
|||||||
prevProps.virtualRow.index === nextProps.virtualRow.index &&
|
prevProps.virtualRow.index === nextProps.virtualRow.index &&
|
||||||
prevProps.virtualRow.start === nextProps.virtualRow.start &&
|
prevProps.virtualRow.start === nextProps.virtualRow.start &&
|
||||||
prevProps.virtualRow.size === nextProps.virtualRow.size &&
|
prevProps.virtualRow.size === nextProps.virtualRow.size &&
|
||||||
prevProps.isLoading === nextProps.isLoading
|
prevProps.isLoading === nextProps.isLoading &&
|
||||||
|
prevProps.headerColumnsHash === nextProps.headerColumnsHash
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const VirtualizedSessionsTable = memo(
|
const VirtualizedSessionsTable = ({
|
||||||
function VirtualizedSessionsTable({
|
table,
|
||||||
table,
|
data,
|
||||||
data,
|
isLoading,
|
||||||
isLoading,
|
}: VirtualizedSessionsTableProps) => {
|
||||||
}: VirtualizedSessionsTableProps) {
|
const parentRef = useRef<HTMLDivElement>(null);
|
||||||
const parentRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const headerColumns = useMemo(
|
const headerColumns = table.getAllLeafColumns().filter((col) => {
|
||||||
() =>
|
return table.getState().columnVisibility[col.id] !== false;
|
||||||
table.getAllLeafColumns().filter((col) => {
|
});
|
||||||
return table.getState().columnVisibility[col.id] !== false;
|
|
||||||
}),
|
|
||||||
[table],
|
|
||||||
);
|
|
||||||
|
|
||||||
const rowVirtualizer = useWindowVirtualizer({
|
const rowVirtualizer = useWindowVirtualizer({
|
||||||
count: data.length,
|
count: data.length,
|
||||||
estimateSize: () => ROW_HEIGHT, // Estimated row height
|
estimateSize: () => ROW_HEIGHT, // Estimated row height
|
||||||
overscan: 10,
|
overscan: 10,
|
||||||
scrollMargin: parentRef.current?.offsetTop ?? 0,
|
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
|
<div
|
||||||
ref={parentRef}
|
className="sticky top-0 z-10 bg-card border-b"
|
||||||
className="w-full overflow-x-auto border rounded-md bg-card"
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: headerColumns
|
||||||
|
.map((col) => `${col.getSize()}px`)
|
||||||
|
.join(' '),
|
||||||
|
minWidth: 'fit-content',
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{/* Table Header */}
|
{headerColumns.map((column) => {
|
||||||
<div
|
const header = column.columnDef.header;
|
||||||
className="sticky top-0 z-10 bg-card border-b"
|
const width = `${column.getSize()}px`;
|
||||||
style={{
|
return (
|
||||||
display: 'grid',
|
<div
|
||||||
gridTemplateColumns: headerColumns
|
key={column.id}
|
||||||
.map((col) => `${col.getSize()}px`)
|
className="flex items-center h-10 px-4 text-left text-[10px] uppercase text-foreground font-semibold whitespace-nowrap"
|
||||||
.join(' '),
|
style={{
|
||||||
minWidth: 'fit-content',
|
width,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{headerColumns.map((column) => {
|
{typeof header === 'function' ? header({} as any) : header}
|
||||||
const header = column.columnDef.header;
|
</div>
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
|
||||||
},
|
{!isLoading && data.length === 0 && (
|
||||||
arePropsEqual(['data', 'isLoading']),
|
<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) => {
|
export const SessionsTable = ({ query }: Props) => {
|
||||||
const { isLoading } = query;
|
const { isLoading } = query;
|
||||||
@@ -227,7 +257,7 @@ export const SessionsTable = ({ query }: Props) => {
|
|||||||
|
|
||||||
// const { setPage, state: pagination } = useDataTablePagination();
|
// const { setPage, state: pagination } = useDataTablePagination();
|
||||||
const { columnVisibility, setColumnVisibility } =
|
const { columnVisibility, setColumnVisibility } =
|
||||||
useDataTableColumnVisibility(columns);
|
usePersistentColumnVisibility(columns);
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data,
|
data,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export const InvitesTable = ({ query }: Props) => {
|
|||||||
const columns = useColumns();
|
const columns = useColumns();
|
||||||
const { data, isLoading } = query;
|
const { data, isLoading } = query;
|
||||||
const { table } = useTable({
|
const { table } = useTable({
|
||||||
|
name: 'invites',
|
||||||
columns,
|
columns,
|
||||||
data: data ?? [],
|
data: data ?? [],
|
||||||
loading: isLoading,
|
loading: isLoading,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export const MembersTable = ({ query }: Props) => {
|
|||||||
const columns = useColumns();
|
const columns = useColumns();
|
||||||
const { data, isLoading } = query;
|
const { data, isLoading } = query;
|
||||||
const { table } = useTable({
|
const { table } = useTable({
|
||||||
|
name: 'members',
|
||||||
columns,
|
columns,
|
||||||
data: data ?? [],
|
data: data ?? [],
|
||||||
loading: isLoading,
|
loading: isLoading,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
} from '@tanstack/react-table';
|
} from '@tanstack/react-table';
|
||||||
import { parseAsInteger, useQueryState } from 'nuqs';
|
import { parseAsInteger, useQueryState } from 'nuqs';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { useLocalStorage } from 'usehooks-ts';
|
||||||
|
|
||||||
export const useDataTablePagination = (pageSize = 10) => {
|
export const useDataTablePagination = (pageSize = 10) => {
|
||||||
const [page, setPage] = useQueryState(
|
const [page, setPage] = useQueryState(
|
||||||
@@ -23,12 +24,29 @@ export const useDataTablePagination = (pageSize = 10) => {
|
|||||||
|
|
||||||
export const useDataTableColumnVisibility = <TData,>(
|
export const useDataTableColumnVisibility = <TData,>(
|
||||||
columns: ColumnDef<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) => {
|
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;
|
return acc;
|
||||||
}, {} as VisibilityState),
|
}, {} 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,
|
PopoverTrigger,
|
||||||
} from '@/components/ui/popover';
|
} from '@/components/ui/popover';
|
||||||
import { cn } from '@/lib/utils';
|
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 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';
|
import * as React from 'react';
|
||||||
|
|
||||||
interface DataTableViewOptionsProps<TData> {
|
interface DataTableViewOptionsProps<TData> {
|
||||||
table: Table<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>({
|
export function DataTableViewOptions<TData>({
|
||||||
table,
|
table,
|
||||||
}: DataTableViewOptionsProps<TData>) {
|
}: DataTableViewOptionsProps<TData>) {
|
||||||
const columns = React.useMemo(
|
const allColumns = table.getAllColumns();
|
||||||
() =>
|
const filterableColumns = allColumns.filter(
|
||||||
table
|
(column) => typeof column.accessorFn !== 'undefined' && column.getCanHide(),
|
||||||
.getAllColumns()
|
|
||||||
.filter(
|
|
||||||
(column) =>
|
|
||||||
typeof column.accessorFn !== 'undefined' && column.getCanHide(),
|
|
||||||
),
|
|
||||||
[table],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 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 (
|
return (
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
@@ -50,32 +184,41 @@ export function DataTableViewOptions<TData>({
|
|||||||
<ChevronsUpDown className="opacity-50 ml-2 size-4" />
|
<ChevronsUpDown className="opacity-50 ml-2 size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent align="end" className="w-44 p-0">
|
<PopoverContent align="end" className="w-52 p-0">
|
||||||
<Command>
|
<Command>
|
||||||
<CommandInput placeholder="Search columns..." />
|
<CommandInput placeholder="Search columns..." />
|
||||||
<CommandList>
|
<CommandList>
|
||||||
<CommandEmpty>No columns found.</CommandEmpty>
|
<CommandEmpty>No columns found.</CommandEmpty>
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
{columns.map((column) => (
|
<DndContext
|
||||||
<CommandItem
|
sensors={sensors}
|
||||||
key={column.id}
|
collisionDetection={closestCenter}
|
||||||
onSelect={() =>
|
onDragEnd={handleDragEnd}
|
||||||
column.toggleVisibility(!column.getIsVisible())
|
>
|
||||||
}
|
<SortableContext
|
||||||
|
items={columns.map((col) => col.id)}
|
||||||
|
strategy={verticalListSortingStrategy}
|
||||||
>
|
>
|
||||||
<span className="truncate">
|
{columns.map((column) => (
|
||||||
{typeof column.columnDef.header === 'string'
|
<SortableColumnItem
|
||||||
? column.columnDef.header
|
key={column.id}
|
||||||
: (column.columnDef.meta?.label ?? column.id)}
|
column={column}
|
||||||
</span>
|
onToggleVisibility={() =>
|
||||||
<Check
|
column.toggleVisibility(!column.getIsVisible())
|
||||||
className={cn(
|
}
|
||||||
'ml-auto size-4 shrink-0',
|
/>
|
||||||
column.getIsVisible() ? 'opacity-100' : 'opacity-0',
|
))}
|
||||||
)}
|
</SortableContext>
|
||||||
/>
|
</DndContext>
|
||||||
</CommandItem>
|
</CommandGroup>
|
||||||
))}
|
<CommandGroup>
|
||||||
|
<CommandItem
|
||||||
|
onSelect={handleReset}
|
||||||
|
className="text-muted-foreground"
|
||||||
|
>
|
||||||
|
<RotateCcw className="size-4 mr-2" />
|
||||||
|
Reset to default
|
||||||
|
</CommandItem>
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
</CommandList>
|
</CommandList>
|
||||||
</Command>
|
</Command>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
useQueryStates,
|
useQueryStates,
|
||||||
} from 'nuqs';
|
} from 'nuqs';
|
||||||
import React, { useMemo, useState } from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
|
import { useDataTableColumnVisibility } from './data-table-hooks';
|
||||||
|
|
||||||
const nuqsOptions: Options = {
|
const nuqsOptions: Options = {
|
||||||
shallow: true,
|
shallow: true,
|
||||||
@@ -35,11 +36,13 @@ export function useTable<TData>({
|
|||||||
pageSize,
|
pageSize,
|
||||||
data,
|
data,
|
||||||
loading,
|
loading,
|
||||||
|
name,
|
||||||
}: {
|
}: {
|
||||||
columns: ColumnDef<TData>[];
|
columns: ColumnDef<TData>[];
|
||||||
pageSize: number;
|
pageSize: number;
|
||||||
data: TData[];
|
data: TData[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
|
name: string;
|
||||||
}) {
|
}) {
|
||||||
const [page, setPage] = useQueryState(
|
const [page, setPage] = useQueryState(
|
||||||
'page',
|
'page',
|
||||||
@@ -54,6 +57,9 @@ export function useTable<TData>({
|
|||||||
pageSize: perPage,
|
pageSize: perPage,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const { columnVisibility, setColumnVisibility, columnOrder, setColumnOrder } =
|
||||||
|
useDataTableColumnVisibility(columns, name);
|
||||||
|
|
||||||
const [columnPinning, setColumnPinning] = useState<ColumnPinningState>({
|
const [columnPinning, setColumnPinning] = useState<ColumnPinningState>({
|
||||||
left: [
|
left: [
|
||||||
...columns
|
...columns
|
||||||
@@ -149,6 +155,9 @@ export function useTable<TData>({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
|
meta: {
|
||||||
|
name,
|
||||||
|
},
|
||||||
columns,
|
columns,
|
||||||
data: useMemo(
|
data: useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -181,7 +190,11 @@ export function useTable<TData>({
|
|||||||
pagination,
|
pagination,
|
||||||
columnPinning,
|
columnPinning,
|
||||||
columnFilters: loading ? [] : columnFilters,
|
columnFilters: loading ? [] : columnFilters,
|
||||||
|
columnOrder,
|
||||||
|
columnVisibility,
|
||||||
},
|
},
|
||||||
|
onColumnOrderChange: setColumnOrder,
|
||||||
|
onColumnVisibilityChange: setColumnVisibility,
|
||||||
onColumnPinningChange: setColumnPinning,
|
onColumnPinningChange: setColumnPinning,
|
||||||
onColumnFiltersChange: (updaterOrValue: Updater<ColumnFiltersState>) => {
|
onColumnFiltersChange: (updaterOrValue: Updater<ColumnFiltersState>) => {
|
||||||
setColumnFilters((prev) => {
|
setColumnFilters((prev) => {
|
||||||
|
|||||||
@@ -57,7 +57,12 @@ export function WidgetBody({ children, className }: WidgetBodyProps) {
|
|||||||
export interface WidgetProps {
|
export interface WidgetProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
ref?: React.RefObject<HTMLDivElement | null>;
|
||||||
}
|
}
|
||||||
export function Widget({ children, className }: WidgetProps) {
|
export function Widget({ children, className, ...props }: WidgetProps) {
|
||||||
return <div className={cn('card self-start', className)}>{children}</div>;
|
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(() => {
|
.server(() => {
|
||||||
return getRequestHeaders();
|
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
|
// Create a function that returns a tRPC client with optional cookies
|
||||||
export function createTRPCClientWithHeaders(apiUrl: string) {
|
export function createTRPCClientWithHeaders(apiUrl: string) {
|
||||||
|
|||||||
@@ -17,9 +17,16 @@ import FullPageLoadingState from '@/components/full-page-loading-state';
|
|||||||
import { Providers } from '@/components/providers';
|
import { Providers } from '@/components/providers';
|
||||||
import { ThemeScriptOnce } from '@/components/theme-provider';
|
import { ThemeScriptOnce } from '@/components/theme-provider';
|
||||||
import { LinkButton } from '@/components/ui/button';
|
import { LinkButton } from '@/components/ui/button';
|
||||||
|
import { getCookiesFn } from '@/hooks/use-cookie-store';
|
||||||
import { useSessionExtension } from '@/hooks/use-session-extension';
|
import { useSessionExtension } from '@/hooks/use-session-extension';
|
||||||
import { op } from '@/utils/op';
|
import { op } from '@/utils/op';
|
||||||
import type { AppRouter } from '@openpanel/trpc';
|
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';
|
import type { TRPCOptionsProxy } from '@trpc/tanstack-react-query';
|
||||||
|
|
||||||
op.init();
|
op.init();
|
||||||
@@ -33,17 +40,20 @@ interface MyRouterContext {
|
|||||||
|
|
||||||
export const Route = createRootRouteWithContext<MyRouterContext>()({
|
export const Route = createRootRouteWithContext<MyRouterContext>()({
|
||||||
beforeLoad: async ({ context }) => {
|
beforeLoad: async ({ context }) => {
|
||||||
const session = await context.queryClient.ensureQueryData(
|
const [session, cookies] = await Promise.all([
|
||||||
context.trpc.auth.session.queryOptions(undefined, {
|
context.queryClient.ensureQueryData(
|
||||||
staleTime: 1000 * 60 * 5,
|
context.trpc.auth.session.queryOptions(undefined, {
|
||||||
gcTime: 1000 * 60 * 10,
|
staleTime: 1000 * 60 * 5,
|
||||||
refetchOnWindowFocus: false,
|
gcTime: 1000 * 60 * 10,
|
||||||
refetchOnMount: false,
|
refetchOnWindowFocus: false,
|
||||||
refetchOnReconnect: false,
|
refetchOnMount: false,
|
||||||
}),
|
refetchOnReconnect: false,
|
||||||
);
|
}),
|
||||||
|
),
|
||||||
|
getCookiesFn(),
|
||||||
|
]);
|
||||||
|
|
||||||
return { session };
|
return { session, cookies };
|
||||||
},
|
},
|
||||||
head: () => ({
|
head: () => ({
|
||||||
meta: [
|
meta: [
|
||||||
|
|||||||
@@ -59,13 +59,15 @@ function Component() {
|
|||||||
<PageContainer>
|
<PageContainer>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title={
|
title={
|
||||||
<div className="row items-center gap-4">
|
<div className="row items-center gap-4 min-w-0">
|
||||||
<ProfileAvatar {...profile.data} />
|
<ProfileAvatar {...profile.data} />
|
||||||
{getProfileName(profile.data, false)}
|
<span className="truncate">
|
||||||
|
{getProfileName(profile.data, false)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="row gap-4 mb-6">
|
<div className="row gap-4 mb-6 flex-wrap">
|
||||||
{profile.data?.properties.country && (
|
{profile.data?.properties.country && (
|
||||||
<div className="row gap-2 items-center">
|
<div className="row gap-2 items-center">
|
||||||
<SerieIcon name={profile.data.properties.country} />
|
<SerieIcon name={profile.data.properties.country} />
|
||||||
|
|||||||
@@ -163,6 +163,7 @@ function Component() {
|
|||||||
const data = query.data ?? [];
|
const data = query.data ?? [];
|
||||||
|
|
||||||
const { table, loading } = useTable({
|
const { table, loading } = useTable({
|
||||||
|
name: 'references',
|
||||||
columns: columnDefs,
|
columns: columnDefs,
|
||||||
data,
|
data,
|
||||||
pageSize: 30,
|
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
|
||||||
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 = {
|
this._fill = {
|
||||||
from: this.escapeDate(from),
|
from:
|
||||||
to: this.escapeDate(to),
|
from instanceof Expression ? from.toString() : this.escapeDate(from),
|
||||||
step: step,
|
to: to instanceof Expression ? to.toString() : this.escapeDate(to),
|
||||||
|
step: step instanceof Expression ? step.toString() : step,
|
||||||
};
|
};
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -165,16 +165,6 @@ export class OverviewService {
|
|||||||
views_per_session: number;
|
views_per_session: number;
|
||||||
}[];
|
}[];
|
||||||
}> {
|
}> {
|
||||||
console.log('-----------------');
|
|
||||||
console.log('getMetrics', {
|
|
||||||
projectId,
|
|
||||||
filters,
|
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
interval,
|
|
||||||
timezone,
|
|
||||||
});
|
|
||||||
|
|
||||||
const where = this.getRawWhereClause('sessions', filters);
|
const where = this.getRawWhereClause('sessions', filters);
|
||||||
if (this.isPageFilter(filters)) {
|
if (this.isPageFilter(filters)) {
|
||||||
// Session aggregation with bounce rates
|
// Session aggregation with bounce rates
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
|
TABLE_NAMES,
|
||||||
|
ch,
|
||||||
|
clix,
|
||||||
eventBuffer,
|
eventBuffer,
|
||||||
getChartPrevStartEndDate,
|
getChartPrevStartEndDate,
|
||||||
getChartStartEndDate,
|
getChartStartEndDate,
|
||||||
@@ -14,8 +17,12 @@ import { format } from 'date-fns';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { cacheMiddleware, createTRPCRouter, publicProcedure } from '../trpc';
|
import { cacheMiddleware, createTRPCRouter, publicProcedure } from '../trpc';
|
||||||
|
|
||||||
const cacher = cacheMiddleware((input) => {
|
const cacher = cacheMiddleware((input, opts) => {
|
||||||
const range = input.range as IChartRange;
|
const range = input.range as IChartRange;
|
||||||
|
if (opts.path === 'overview.liveData') {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
switch (range) {
|
switch (range) {
|
||||||
case '30min':
|
case '30min':
|
||||||
case 'today':
|
case 'today':
|
||||||
@@ -82,6 +89,125 @@ export const overviewRouter = createTRPCRouter({
|
|||||||
.query(async ({ input }) => {
|
.query(async ({ input }) => {
|
||||||
return eventBuffer.getActiveVisitorCount(input.projectId);
|
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
|
stats: publicProcedure
|
||||||
.input(
|
.input(
|
||||||
zGetMetricsInput.omit({ startDate: true, endDate: true }).extend({
|
zGetMetricsInput.omit({ startDate: true, endDate: true }).extend({
|
||||||
|
|||||||
@@ -169,8 +169,15 @@ const middlewareMarker = 'middlewareMarker' as 'middlewareMarker' & {
|
|||||||
__brand: '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 }) => {
|
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();
|
const rawInput = await getRawInput();
|
||||||
if (type !== 'query') {
|
if (type !== 'query') {
|
||||||
return next();
|
return next();
|
||||||
@@ -194,7 +201,7 @@ export const cacheMiddleware = (cbOrTtl: number | ((input: any) => number)) =>
|
|||||||
if (result.data) {
|
if (result.data) {
|
||||||
getRedisCache().setJson(
|
getRedisCache().setJson(
|
||||||
key,
|
key,
|
||||||
typeof cbOrTtl === 'function' ? cbOrTtl(input) : cbOrTtl,
|
ttl,
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
result.data,
|
result.data,
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user