fix: improvements in the dashboard

This commit is contained in:
Carl-Gerhard Lindesvärd
2025-10-17 18:15:23 +02:00
parent c8bea685db
commit 077a47a263
29 changed files with 1133 additions and 526 deletions

View File

@@ -18,6 +18,7 @@ export const ClientsTable = ({ query }: Props) => {
const { data, isLoading } = query;
const { table } = useTable({
name: 'clients',
columns,
data: data ?? [],
loading: isLoading,

View File

@@ -3,16 +3,34 @@ import { ProjectLink } from '@/components/links';
import { SerieIcon } from '@/components/report-chart/common/serie-icon';
import { useNumber } from '@/hooks/use-numer-formatter';
import { pushModal } from '@/modals';
import { formatDateTime, formatTime } from '@/utils/date';
import { formatDateTime, formatTimeAgoOrDateTime } from '@/utils/date';
import { getProfileName } from '@/utils/getters';
import type { ColumnDef } from '@tanstack/react-table';
import { isToday } from 'date-fns';
import { KeyValueGrid } from '@/components/ui/key-value-grid';
import type { IServiceEvent } from '@openpanel/db';
export function useColumns() {
const number = useNumber();
const columns: ColumnDef<IServiceEvent>[] = [
{
accessorKey: 'createdAt',
header: 'Created at',
size: 140,
cell: ({ row }) => {
const session = row.original;
return (
<div className="relative">
<div className="absolute inset-0 opacity-0 group-hover/row:opacity-100 transition-opacity duration-100">
{formatDateTime(session.createdAt)}
</div>
<div className="text-muted-foreground group-hover/row:opacity-0 transition-opacity duration-100">
{formatTimeAgoOrDateTime(session.createdAt)}
</div>
</div>
);
},
},
{
size: 300,
accessorKey: 'name',
@@ -85,17 +103,6 @@ export function useColumns() {
);
},
},
{
accessorKey: 'createdAt',
header: 'Created at',
size: 170,
cell({ row }) {
const date = row.original.createdAt;
return (
<div>{isToday(date) ? formatTime(date) : formatDateTime(date)}</div>
);
},
},
{
accessorKey: 'profileId',
header: 'Profile',
@@ -141,11 +148,17 @@ export function useColumns() {
accessorKey: 'sessionId',
header: 'Session ID',
size: 320,
meta: {
hidden: true,
},
},
{
accessorKey: 'deviceId',
header: 'Device ID',
size: 320,
meta: {
hidden: true,
},
},
{
accessorKey: 'country',
@@ -193,6 +206,9 @@ export function useColumns() {
accessorKey: 'properties',
header: 'Properties',
size: 400,
meta: {
hidden: true,
},
cell({ row }) {
const { properties } = row.original;
const filteredProperties = Object.fromEntries(
@@ -201,19 +217,23 @@ export function useColumns() {
),
);
const items = Object.entries(filteredProperties);
return (
<div className="row flex-wrap gap-x-4 gap-y-1 overflow-hidden text-sm">
{items.slice(0, 4).map(([key, value]) => (
<div key={key} className="row items-center gap-1 min-w-0">
<span className="text-muted-foreground">{key}</span>
<span className="truncate font-medium">{String(value)}</span>
</div>
))}
{items.length > 5 && (
<span className="truncate">{items.length - 5} more</span>
)}
</div>
);
const limit = 1;
const data = items.slice(0, limit).map(([key, value]) => ({
name: key,
value: value,
}));
if (items.length > limit) {
data.push({
name: '',
value: `${items.length - limit} more item${items.length - limit === 1 ? '' : 's'}`,
});
}
if (data.length === 0) {
return null;
}
return <KeyValueGrid className="w-full" data={data} />;
},
},
];

View File

@@ -1,30 +1,22 @@
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import {
Popover,
PopoverContent,
PopoverPortal,
PopoverTrigger,
} from '@/components/ui/popover';
import { Check, ChevronsUpDown, Settings2Icon } from 'lucide-react';
import { FullPageEmptyState } from '@/components/full-page-empty-state';
import { OverviewFiltersButtons } from '@/components/overview/filters/overview-filters-buttons';
import { OverviewFiltersDrawer } from '@/components/overview/filters/overview-filters-drawer';
import { Skeleton } from '@/components/skeleton';
import { Button } from '@/components/ui/button';
import { useDataTableColumnVisibility } from '@/components/ui/data-table/data-table-hooks';
import { DataTableToolbarContainer } from '@/components/ui/data-table/data-table-toolbar';
import { DataTableViewOptions } from '@/components/ui/data-table/data-table-view-options';
import { useAppParams } from '@/hooks/use-app-params';
import { pushModal } from '@/modals';
import type { RouterInputs, RouterOutputs } from '@/trpc/client';
import { arePropsEqual } from '@/utils/are-props-equal';
import { cn } from '@/utils/cn';
import type { IServiceEvent } from '@openpanel/db';
import type { UseInfiniteQueryResult } from '@tanstack/react-query';
import type { Table } from '@tanstack/react-table';
import { getCoreRowModel, useReactTable } from '@tanstack/react-table';
import { Updater } from '@tanstack/react-table';
import { ColumnOrderState } from '@tanstack/react-table';
import { useWindowVirtualizer } from '@tanstack/react-virtual';
import type { TRPCInfiniteData } from '@trpc/tanstack-react-query';
import { format } from 'date-fns';
@@ -32,20 +24,11 @@ import throttle from 'lodash.throttle';
import { CalendarIcon, Loader2Icon } from 'lucide-react';
import { parseAsIsoDateTime, useQueryState } from 'nuqs';
import { last } from 'ramda';
import { memo, useEffect, useRef, useState } from 'react';
import { memo, useEffect, useMemo, useRef, useState } from 'react';
import { useInViewport } from 'react-in-viewport';
import { useLocalStorage } from 'usehooks-ts';
import EventListener from '../event-listener';
import { EventItem, EventItemSkeleton } from './item';
export const useEventsViewOptions = () => {
return useLocalStorage<Record<string, boolean | undefined>>(
'@op:events-table-view-options',
{
properties: false,
},
);
};
import { useColumns } from './columns';
type Props = {
query: UseInfiniteQueryResult<
@@ -57,137 +40,263 @@ type Props = {
>;
};
export const EventsTable = memo(
({ query }: Props) => {
const [viewOptions] = useEventsViewOptions();
const { isLoading } = query;
const parentRef = useRef<HTMLDivElement>(null);
const [scrollMargin, setScrollMargin] = useState(0);
const inViewportRef = useRef<HTMLDivElement>(null);
const { inViewport, enterCount } = useInViewport(inViewportRef, undefined, {
disconnectOnLeave: true,
});
const LOADING_DATA = [{}, {}, {}, {}, {}, {}, {}, {}, {}] as IServiceEvent[];
const ROW_HEIGHT = 40;
const data = query.data?.pages?.flatMap((p) => p.data) ?? [];
interface VirtualizedEventsTableProps {
table: Table<IServiceEvent>;
data: IServiceEvent[];
isLoading: boolean;
}
const virtualizer = useWindowVirtualizer({
count: data.length,
estimateSize: () => 55,
scrollMargin,
overscan: 10,
});
useEffect(() => {
const updateScrollMargin = throttle(() => {
if (parentRef.current) {
setScrollMargin(
parentRef.current.getBoundingClientRect().top + window.scrollY,
);
}
}, 500);
// Initial calculation
updateScrollMargin();
// Listen for resize events
window.addEventListener('resize', updateScrollMargin);
return () => {
window.removeEventListener('resize', updateScrollMargin);
};
}, []);
useEffect(() => {
virtualizer.measure();
}, [viewOptions, virtualizer]);
const hasNextPage = last(query.data?.pages ?? [])?.meta.next;
useEffect(() => {
if (
hasNextPage &&
data.length > 0 &&
inViewport &&
enterCount > 0 &&
query.isFetchingNextPage === false
) {
query.fetchNextPage();
}
}, [inViewport, enterCount, hasNextPage]);
const visibleItems = virtualizer.getVirtualItems();
interface VirtualRowProps {
row: any;
virtualRow: any;
headerColumns: any[];
scrollMargin: number;
isLoading: boolean;
headerColumnsHash: string;
}
const VirtualRow = memo(
function VirtualRow({
row,
virtualRow,
headerColumns,
scrollMargin,
isLoading,
}: VirtualRowProps) {
return (
<>
<EventsTableToolbar query={query} />
<div ref={parentRef} className="w-full">
{isLoading && (
<div className="w-full gap-2 col">
<EventItemSkeleton />
<EventItemSkeleton />
<EventItemSkeleton />
<EventItemSkeleton />
<EventItemSkeleton />
<EventItemSkeleton />
<div
key={virtualRow.key}
data-index={virtualRow.index}
ref={virtualRow.measureElement}
className="absolute top-0 left-0 w-full border-b hover:bg-muted/50 transition-colors group/row"
style={{
transform: `translateY(${virtualRow.start - scrollMargin}px)`,
display: 'grid',
gridTemplateColumns: headerColumns
.map((col) => `${col.getSize()}px`)
.join(' '),
minWidth: 'fit-content',
minHeight: ROW_HEIGHT,
}}
>
{row.getVisibleCells().map((cell: any) => {
const width = `${cell.column.getSize()}px`;
return (
<div
key={cell.id}
className="flex items-center p-2 px-4 align-middle whitespace-nowrap"
style={{
width,
overflow: 'hidden',
}}
>
{isLoading ? (
<Skeleton className="h-4 w-3/5" />
) : cell.column.columnDef.cell ? (
typeof cell.column.columnDef.cell === 'function' ? (
cell.column.columnDef.cell(cell.getContext())
) : (
cell.column.columnDef.cell
)
) : (
(cell.getValue() as React.ReactNode)
)}
</div>
)}
{!isLoading && data.length === 0 && (
<FullPageEmptyState
title="No events"
description={"Start sending events and you'll see them here"}
/>
)}
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{visibleItems.map((virtualRow) => (
<div
key={virtualRow.index}
data-index={virtualRow.index}
ref={virtualizer.measureElement}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${
virtualRow.start - virtualizer.options.scrollMargin
}px)`,
paddingBottom: '8px', // Gap between items
}}
>
<EventItem
event={data[virtualRow.index]!}
viewOptions={viewOptions}
/>
</div>
))}
</div>
</div>
<div className="w-full h-10 center-center pt-4" ref={inViewportRef}>
<div
className={cn(
'size-8 bg-background rounded-full center-center border opacity-0 transition-opacity',
query.isFetchingNextPage && 'opacity-100',
)}
>
<Loader2Icon className="size-4 animate-spin" />
</div>
</div>
</>
);
})}
</div>
);
},
(prevProps, nextProps) => {
return (
prevProps.row.id === nextProps.row.id &&
prevProps.virtualRow.index === nextProps.virtualRow.index &&
prevProps.virtualRow.start === nextProps.virtualRow.start &&
prevProps.virtualRow.size === nextProps.virtualRow.size &&
prevProps.isLoading === nextProps.isLoading &&
prevProps.headerColumnsHash === nextProps.headerColumnsHash
);
},
arePropsEqual(['query.isLoading', 'query.data', 'query.isFetchingNextPage']),
);
const VirtualizedEventsTable = ({
table,
data,
isLoading,
}: VirtualizedEventsTableProps) => {
const parentRef = useRef<HTMLDivElement>(null);
const headerColumns = table.getAllLeafColumns().filter((col) => {
return table.getState().columnVisibility[col.id] !== false;
});
const rowVirtualizer = useWindowVirtualizer({
count: data.length,
estimateSize: () => ROW_HEIGHT,
overscan: 10,
scrollMargin: parentRef.current?.offsetTop ?? 0,
});
useEffect(() => {
rowVirtualizer.measure();
}, [headerColumns.length]);
const virtualRows = rowVirtualizer.getVirtualItems();
const headerColumnsHash = headerColumns.map((col) => col.id).join(',');
return (
<div
ref={parentRef}
className="w-full overflow-x-auto border rounded-md bg-card"
>
{/* Table Header */}
<div
className="sticky top-0 z-10 bg-card border-b"
style={{
display: 'grid',
gridTemplateColumns: headerColumns
.map((col) => `${col.getSize()}px`)
.join(' '),
minWidth: 'fit-content',
}}
>
{headerColumns.map((column) => {
const header = column.columnDef.header;
const width = `${column.getSize()}px`;
return (
<div
key={column.id}
className="flex items-center h-10 px-4 text-left text-[10px] uppercase text-foreground font-semibold whitespace-nowrap"
style={{
width,
}}
>
{typeof header === 'function' ? header({} as any) : header}
</div>
);
})}
</div>
{!isLoading && data.length === 0 && (
<FullPageEmptyState
title="No events"
description={"Start sending events and you'll see them here"}
/>
)}
{/* Table Body */}
<div
className="relative w-full"
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
minHeight: 'fit-content',
minWidth: 'fit-content',
}}
>
{virtualRows.map((virtualRow) => {
const row = table.getRowModel().rows[virtualRow.index];
if (!row) return null;
return (
<VirtualRow
key={virtualRow.key}
row={row}
virtualRow={{
...virtualRow,
measureElement: rowVirtualizer.measureElement,
}}
headerColumns={headerColumns}
headerColumnsHash={headerColumnsHash}
scrollMargin={rowVirtualizer.options.scrollMargin}
isLoading={isLoading}
/>
);
})}
</div>
</div>
);
};
export const EventsTable = ({ query }: Props) => {
const { isLoading } = query;
const columns = useColumns();
const data = useMemo(() => {
if (isLoading) {
return LOADING_DATA;
}
return query.data?.pages?.flatMap((p) => p.data) ?? [];
}, [query.data, isLoading]);
const { columnVisibility, setColumnVisibility, columnOrder, setColumnOrder } =
useDataTableColumnVisibility(columns, 'events');
const table = useReactTable({
data,
getCoreRowModel: getCoreRowModel(),
manualPagination: true,
manualFiltering: true,
manualSorting: true,
columns,
rowCount: 50,
pageCount: 1,
filterFns: {
isWithinRange: () => true,
},
state: {
columnVisibility,
columnOrder,
},
onColumnVisibilityChange: setColumnVisibility,
onColumnOrderChange: setColumnOrder,
});
const inViewportRef = useRef<HTMLDivElement>(null);
const { inViewport, enterCount } = useInViewport(inViewportRef, undefined, {
disconnectOnLeave: true,
});
const hasNextPage = last(query.data?.pages ?? [])?.meta.next;
useEffect(() => {
if (
hasNextPage &&
data.length > 0 &&
inViewport &&
enterCount > 0 &&
query.isFetchingNextPage === false
) {
query.fetchNextPage();
}
}, [inViewport, enterCount, hasNextPage]);
return (
<>
<EventsTableToolbar query={query} table={table} />
<VirtualizedEventsTable table={table} data={data} isLoading={isLoading} />
<div className="w-full h-10 center-center pt-4" ref={inViewportRef}>
<div
className={cn(
'size-8 bg-background rounded-full center-center border opacity-0 transition-opacity',
query.isFetchingNextPage && 'opacity-100',
)}
>
<Loader2Icon className="size-4 animate-spin" />
</div>
</div>
</>
);
};
function EventsTableToolbar({
query,
table,
}: {
query: Props['query'];
table: Table<IServiceEvent>;
}) {
const { projectId } = useAppParams();
const [startDate, setStartDate] = useQueryState(
@@ -195,6 +304,7 @@ function EventsTableToolbar({
parseAsIsoDateTime,
);
const [endDate, setEndDate] = useQueryState('endDate', parseAsIsoDateTime);
return (
<DataTableToolbarContainer>
<div className="flex flex-1 flex-wrap items-center gap-2">
@@ -225,74 +335,7 @@ function EventsTableToolbar({
/>
<OverviewFiltersButtons className="justify-end p-0" />
</div>
<EventsViewOptions />
<DataTableViewOptions table={table} />
</DataTableToolbarContainer>
);
}
export function EventsViewOptions() {
const [viewOptions, setViewOptions] = useEventsViewOptions();
const columns = {
origin: 'Show origin',
queryString: 'Show query string',
referrer: 'Referrer',
country: 'Country',
os: 'OS',
browser: 'Browser',
profileId: 'Profile',
createdAt: 'Created at',
properties: 'Properties',
};
return (
<Popover>
<PopoverTrigger asChild>
<Button
aria-label="Toggle columns"
role="combobox"
variant="outline"
size="sm"
className="ml-auto hidden h-8 lg:flex"
>
<Settings2Icon className="size-4 mr-2" />
View
<ChevronsUpDown className="opacity-50 ml-2 size-4" />
</Button>
</PopoverTrigger>
<PopoverPortal>
<PopoverContent align="end" className="w-44 p-0">
<Command>
<CommandInput placeholder="Search columns..." />
<CommandList>
<CommandEmpty>No columns found.</CommandEmpty>
<CommandGroup>
{Object.entries(columns).map(([column, label]) => (
<CommandItem
key={column}
onSelect={() =>
setViewOptions({
...viewOptions,
// biome-ignore lint/complexity/noUselessTernary: we need this this viewOptions[column] can be undefined
[column]: viewOptions[column] === false ? true : false,
})
}
>
<span className="truncate">{label}</span>
<Check
className={cn(
'ml-auto size-4 shrink-0',
viewOptions[column] !== false
? 'opacity-100'
: 'opacity-0',
)}
/>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</PopoverPortal>
</Popover>
);
}

View File

@@ -15,7 +15,7 @@ const sellingPoints = [
<SellingPoint
bgImage="/img-1.png"
title="Best open-source alternative"
description="Mixpanel to expensive, Google Analytics has no privacy, Amplitude old and boring"
description="Mixpanel too expensive, Google Analytics has no privacy, Amplitude old and boring"
/>
),
},

View File

@@ -17,6 +17,7 @@ export const NotificationsTable = ({ query }: Props) => {
const columns = useColumns();
const { data, isLoading } = query;
const { table } = useTable({
name: 'notifications',
columns,
data: data ?? [],
loading: isLoading,

View File

@@ -42,7 +42,7 @@ const onboardingSellingPoints = [
<SellingPoint
bgImage="/img-1.png"
title="Best open-source alternative"
description="Mixpanel to expensive, Google Analytics has no privacy, Amplitude old and boring"
description="Mixpanel too expensive, Google Analytics has no privacy, Amplitude old and boring"
/>
),
},

View File

@@ -2,8 +2,6 @@ import { useTRPC } from '@/integrations/trpc/react';
import { cn } from '@/utils/cn';
import { useQuery } from '@tanstack/react-query';
import type { IChartProps } from '@openpanel/validation';
import { useNumber } from '@/hooks/use-numer-formatter';
import { getChartColor } from '@/utils/theme';
import React, { useEffect, useState } from 'react';
@@ -19,6 +17,7 @@ import {
YAxis,
} from 'recharts';
import { BarShapeBlue } from '../charts/common-bar';
import { SerieIcon } from '../report-chart/common/serie-icon';
interface OverviewLiveHistogramProps {
projectId: string;
@@ -27,72 +26,17 @@ interface OverviewLiveHistogramProps {
export function OverviewLiveHistogram({
projectId,
}: OverviewLiveHistogramProps) {
const report: IChartProps = {
projectId,
events: [
{
segment: 'user',
filters: [
{
id: '1',
name: 'name',
operator: 'is',
value: ['screen_view', 'session_start'],
},
],
id: 'A',
name: '*',
displayName: 'Active users',
},
],
chartType: 'histogram',
interval: 'minute',
range: '30min',
name: '',
metric: 'sum',
breakdowns: [],
lineType: 'monotone',
previous: false,
};
const countReport: IChartProps = {
name: '',
projectId,
events: [
{
segment: 'user',
filters: [],
id: 'A',
name: 'session_start',
},
],
breakdowns: [],
chartType: 'metric',
lineType: 'monotone',
interval: 'minute',
range: '30min',
previous: false,
metric: 'sum',
};
const trpc = useTRPC();
const res = useQuery(trpc.chart.chart.queryOptions(report));
const countRes = useQuery(trpc.chart.chart.queryOptions(countReport));
// Use the new liveData endpoint instead of chart props
const { data: liveData, isLoading } = useQuery(
trpc.overview.liveData.queryOptions({ projectId }),
);
const metrics = res.data?.series[0]?.metrics;
const minutes = (res.data?.series[0]?.data || []).slice(-30);
const liveCount = countRes.data?.series[0]?.metrics?.sum ?? 0;
const totalSessions = liveData?.totalSessions ?? 0;
const chartData = liveData?.minuteCounts ?? [];
// Transform data for Recharts
const chartData = minutes.map((minute) => ({
...minute,
timestamp: new Date(minute.date).getTime(),
time: new Date(minute.date).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
}),
}));
if (res.isInitialLoading || countRes.isInitialLoading) {
if (isLoading) {
return (
<Wrapper count={0}>
<div className="h-full w-full animate-pulse bg-def-200 rounded" />
@@ -100,12 +44,30 @@ export function OverviewLiveHistogram({
);
}
if (!res.isSuccess && !countRes.isSuccess) {
if (!liveData) {
return null;
}
const maxDomain =
Math.max(...chartData.map((item) => item.sessionCount)) * 1.2;
return (
<Wrapper count={liveCount}>
<Wrapper
count={totalSessions}
icons={
<div className="row gap-2">
{liveData.referrers.slice(0, 3).map((ref, index) => (
<div
key={`${ref.referrer}-${ref.count}-${index}`}
className="font-bold text-xs row gap-1 items-center"
>
<SerieIcon name={ref.referrer} />
<span>{ref.count}</span>
</div>
))}
</div>
}
>
<div className="h-full w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart
@@ -119,9 +81,9 @@ export function OverviewLiveHistogram({
}}
/>
<XAxis dataKey="time" axisLine={false} tickLine={false} hide />
<YAxis hide />
<YAxis hide domain={[0, maxDomain]} />
<Bar
dataKey="count"
dataKey="sessionCount"
fill="rgba(59, 121, 255, 0.2)"
isAnimationActive={false}
shape={BarShapeBlue}
@@ -137,13 +99,17 @@ export function OverviewLiveHistogram({
interface WrapperProps {
children: React.ReactNode;
count: number;
icons?: React.ReactNode;
}
function Wrapper({ children, count }: WrapperProps) {
function Wrapper({ children, count, icons }: WrapperProps) {
return (
<div className="flex h-full flex-col">
<div className="relative mb-1 text-sm font-medium text-muted-foreground">
{count} unique vistors last 30 minutes
<div className="row gap-2 justify-between">
<div className="relative mb-1 text-sm font-medium text-muted-foreground">
{count} sessions last 30 minutes
</div>
<div>{icons}</div>
</div>
<div className="relative flex h-full w-full flex-1 items-end justify-center gap-2">
{children}
@@ -182,8 +148,8 @@ const CustomTooltip = ({ active, payload, coordinate }: any) => {
const data = payload[0].payload;
// Smart positioning to avoid going out of bounds
const tooltipWidth = 180; // min-w-[180px]
const tooltipHeight = 80; // approximate height
const tooltipWidth = 220; // min-w-[220px] to accommodate referrers
const tooltipHeight = 120; // approximate height with referrers
const offset = 10;
let left = mousePosition.x + offset;
@@ -211,7 +177,7 @@ const CustomTooltip = ({ active, payload, coordinate }: any) => {
const tooltipContent = (
<div
className="flex min-w-[180px] flex-col gap-2 rounded-xl border bg-background/80 p-3 shadow-xl backdrop-blur-sm"
className="flex min-w-[220px] flex-col gap-2 rounded-xl border bg-background/80 p-3 shadow-xl backdrop-blur-sm"
style={{
position: 'fixed',
top,
@@ -221,12 +187,7 @@ const CustomTooltip = ({ active, payload, coordinate }: any) => {
}}
>
<div className="flex justify-between gap-8 text-muted-foreground">
<div>
{new Date(data.date).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})}
</div>
<div>{data.time}</div>
</div>
<React.Fragment>
<div className="flex gap-2">
@@ -235,14 +196,43 @@ const CustomTooltip = ({ active, payload, coordinate }: any) => {
style={{ background: getChartColor(0) }}
/>
<div className="col flex-1 gap-1">
<div className="flex items-center gap-1">Active users</div>
<div className="flex items-center gap-1">Sessions</div>
<div className="flex justify-between gap-8 font-mono font-medium">
<div className="row gap-1">
{number.formatWithUnit(data.count)}
{number.formatWithUnit(data.sessionCount)}
</div>
</div>
</div>
</div>
{data.referrers && data.referrers.length > 0 && (
<div className="mt-2 pt-2 border-t border-border">
<div className="text-xs text-muted-foreground mb-2">Referrers:</div>
<div className="space-y-1">
{data.referrers.slice(0, 3).map((ref: any, index: number) => (
<div
key={`${ref.referrer}-${ref.count}-${index}`}
className="row items-center justify-between text-xs"
>
<div className="row items-center gap-1">
<SerieIcon name={ref.referrer} />
<span
className="truncate max-w-[120px]"
title={ref.referrer}
>
{ref.referrer}
</span>
</div>
<span className="font-mono">{ref.count}</span>
</div>
))}
{data.referrers.length > 3 && (
<div className="text-xs text-muted-foreground">
+{data.referrers.length - 3} more
</div>
)}
</div>
</div>
)}
</React.Fragment>
</div>
);

View File

@@ -2,6 +2,7 @@ import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
import { cn } from '@/utils/cn';
import { useCookieStore } from '@/hooks/use-cookie-store';
import { useDashedStroke } from '@/hooks/use-dashed-stroke';
import { useFormatDateInterval } from '@/hooks/use-format-date-interval';
import { useNumber } from '@/hooks/use-numer-formatter';
@@ -18,11 +19,14 @@ import {
Bar,
CartesianGrid,
ComposedChart,
Customized,
Line,
LineChart,
ResponsiveContainer,
XAxis,
YAxis,
} from 'recharts';
import { useLocalStorage } from 'usehooks-ts';
import { createChartTooltip } from '../charts/chart-tooltip';
import { BarShapeBlue, BarShapeGrey } from '../charts/common-bar';
import { useXAxisProps, useYAxisProps } from '../report-chart/common/axis';
@@ -80,6 +84,11 @@ export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
const [filters] = useEventQueryFilters();
const trpc = useTRPC();
const [chartType, setChartType] = useCookieStore<'bars' | 'lines'>(
'chartType',
'bars',
);
const activeMetric = TITLES[metric]!;
const overviewQuery = useQuery(
trpc.overview.stats.queryOptions({
@@ -132,8 +141,36 @@ export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
</div>
<div className="card p-4">
<div className="text-center mb-3 -mt-1 text-sm font-medium text-muted-foreground">
{activeMetric.title}
<div className="flex items-center justify-between mb-3 -mt-1">
<div className="text-sm font-medium text-muted-foreground">
{activeMetric.title}
</div>
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => setChartType('bars')}
className={cn(
'px-2 py-1 text-xs rounded transition-colors',
chartType === 'bars'
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:text-foreground',
)}
>
Bars
</button>
<button
type="button"
onClick={() => setChartType('lines')}
className={cn(
'px-2 py-1 text-xs rounded transition-colors',
chartType === 'lines'
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:text-foreground',
)}
>
Lines
</button>
</div>
</div>
<div className="w-full h-[150px]">
{overviewQuery.isLoading && <Skeleton className="h-full w-full" />}
@@ -141,6 +178,7 @@ export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
activeMetric={activeMetric}
interval={interval}
data={data}
chartType={chartType}
/>
</div>
</div>
@@ -205,15 +243,142 @@ function Chart({
activeMetric,
interval,
data,
chartType,
}: {
activeMetric: (typeof TITLES)[number];
interval: IInterval;
data: RouterOutputs['overview']['stats']['series'];
chartType: 'bars' | 'lines';
}) {
const xAxisProps = useXAxisProps({ interval });
const yAxisProps = useYAxisProps();
const [activeBar, setActiveBar] = useState(-1);
// Line chart specific logic
let dotIndex = undefined;
if (chartType === 'lines') {
if (interval === 'hour') {
// Find closest index based on times
dotIndex = data.findIndex((item) => {
return isSameHour(item.date, new Date());
});
}
}
const { calcStrokeDasharray, handleAnimationEnd, getStrokeDasharray } =
useDashedStroke({
dotIndex,
});
const lastSerieDataItem = last(data)?.date || new Date();
const useDashedLastLine = (() => {
if (interval === 'hour') {
return isSameHour(lastSerieDataItem, new Date());
}
if (interval === 'day') {
return isSameDay(lastSerieDataItem, new Date());
}
if (interval === 'month') {
return isSameMonth(lastSerieDataItem, new Date());
}
if (interval === 'week') {
return isSameWeek(lastSerieDataItem, new Date());
}
return false;
})();
if (chartType === 'lines') {
return (
<TooltipProvider metric={activeMetric} interval={interval}>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data}>
<Customized component={calcStrokeDasharray} />
<Line
dataKey="calcStrokeDasharray"
legendType="none"
animationDuration={0}
onAnimationEnd={handleAnimationEnd}
/>
<Tooltip />
<YAxis
{...yAxisProps}
domain={[0, activeMetric.key === 'bounce_rate' ? 100 : 'dataMax']}
width={25}
/>
<XAxis {...xAxisProps} />
<CartesianGrid
strokeDasharray="3 3"
horizontal={true}
vertical={false}
className="stroke-border"
/>
<Line
key={`prev_${activeMetric.key}`}
type="linear"
dataKey={`prev_${activeMetric.key}`}
stroke={'oklch(from var(--foreground) l c h / 0.1)'}
strokeWidth={2}
isAnimationActive={false}
dot={
data.length > 90
? false
: {
stroke: 'oklch(from var(--foreground) l c h / 0.1)',
fill: 'var(--def-100)',
strokeWidth: 1.5,
r: 2,
}
}
activeDot={{
stroke: 'oklch(from var(--foreground) l c h / 0.2)',
fill: 'var(--def-100)',
strokeWidth: 1.5,
r: 3,
}}
/>
<Line
key={activeMetric.key}
type="linear"
dataKey={activeMetric.key}
stroke={getChartColor(0)}
strokeWidth={2}
strokeDasharray={
useDashedLastLine
? getStrokeDasharray(activeMetric.key)
: undefined
}
isAnimationActive={false}
dot={
data.length > 90
? false
: {
stroke: getChartColor(0),
fill: 'var(--def-100)',
strokeWidth: 1.5,
r: 3,
}
}
activeDot={{
stroke: getChartColor(0),
fill: 'var(--def-100)',
strokeWidth: 2,
r: 4,
}}
/>
</LineChart>
</ResponsiveContainer>
</TooltipProvider>
);
}
// Bar chart (default)
return (
<TooltipProvider metric={activeMetric} interval={interval}>
<ResponsiveContainer width="100%" height="100%">

View File

@@ -42,7 +42,11 @@ export function OverviewShare({ projectId }: OverviewShareProps) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button icon={data?.public ? Globe2Icon : LockIcon} responsive>
<Button
icon={data?.public ? Globe2Icon : LockIcon}
responsive
loading={query.isLoading}
>
{data?.public ? 'Public' : 'Private'}
</Button>
</DropdownMenuTrigger>

View File

@@ -6,7 +6,7 @@ import {
useQueryState,
} from 'nuqs';
import { getStorageItem, setStorageItem } from '@/utils/storage';
import { useCookieStore } from '@/hooks/use-cookie-store';
import {
getDefaultIntervalByDates,
getDefaultIntervalByRange,
@@ -27,10 +27,14 @@ export function useOverviewOptions() {
'end',
parseAsString.withOptions(nuqsOptions),
);
const [cookieRange, setCookieRange] = useCookieStore<IChartRange>(
'range',
'7d',
);
const [range, setRange] = useQueryState(
'range',
parseAsStringEnum(mapKeys(timeWindows))
.withDefault(getStorageItem('range', '7d'))
.withDefault(cookieRange)
.withOptions({
...nuqsOptions,
clearOnDefault: false,
@@ -69,7 +73,9 @@ export function useOverviewOptions() {
if (value !== 'custom') {
setStartDate(null);
setEndDate(null);
setStorageItem('range', value);
if (value) {
setCookieRange(value);
}
setInterval(null);
}
setRange(value);

View File

@@ -4,8 +4,8 @@ import { useTRPC } from '@/integrations/trpc/react';
import { useQuery } from '@tanstack/react-query';
import { useRouter } from '@tanstack/react-router';
import { ActivityIcon } from 'lucide-react';
import { EventsViewOptions, useEventsViewOptions } from '../events/table';
import { EventItem } from '../events/table/item';
import { useEffect, useRef } from 'react';
import { EventListItem } from '../events/event-list-item';
import {
WidgetAbsoluteButtons,
WidgetHead,
@@ -24,7 +24,6 @@ export const LatestEvents = ({
projectId,
organizationId,
}: Props) => {
const [viewOptions] = useEventsViewOptions();
const router = useRouter();
const trpc = useTRPC();
const query = useQuery(
@@ -45,26 +44,30 @@ export const LatestEvents = ({
});
};
const ref = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (ref.current && scrollRef.current) {
scrollRef.current.style.height = `${ref.current?.getBoundingClientRect().height}px`;
}
}, [query.data?.data?.length]);
return (
<Widget className="w-full overflow-hidden">
<Widget className="w-full overflow-hidden h-full" ref={ref}>
<WidgetHead>
<WidgetTitle icon={ActivityIcon}>Latest Events</WidgetTitle>
<WidgetAbsoluteButtons>
<Button variant="outline" size="sm" onClick={handleShowMore}>
All
</Button>
<EventsViewOptions />
</WidgetAbsoluteButtons>
</WidgetHead>
<ScrollArea className="h-72">
<ScrollArea ref={scrollRef} className="h-0 p-4">
{query.data?.data?.map((event) => (
<EventItem
className="border-0 rounded-none border-b last:border-b-0 [&_[data-slot='inner']]:px-4"
key={event.id}
event={event}
viewOptions={viewOptions}
/>
<div key={event.id} className="mb-4">
<EventListItem {...event} />
</div>
))}
</ScrollArea>
</Widget>

View File

@@ -31,8 +31,12 @@ export const ProfilesTable = memo(
const columns = useColumns(type);
const { setPage, state: pagination } = useDataTablePagination();
const { columnVisibility, setColumnVisibility } =
useDataTableColumnVisibility(columns);
const {
columnVisibility,
setColumnVisibility,
columnOrder,
setColumnOrder,
} = useDataTableColumnVisibility(columns, 'profiles');
const table = useReactTable({
data: isLoading ? LOADING_DATA : (data?.data ?? []),
@@ -51,8 +55,10 @@ export const ProfilesTable = memo(
state: {
pagination,
columnVisibility,
columnOrder,
},
onColumnVisibilityChange: setColumnVisibility,
onColumnOrderChange: setColumnOrder,
onPaginationChange: (updaterOrValue: Updater<PaginationState>) => {
const nextPagination =
typeof updaterOrValue === 'function'

View File

@@ -2,8 +2,41 @@ import type { UseInfiniteQueryResult } from '@tanstack/react-query';
import { useDataTableColumnVisibility } from '@/components/ui/data-table/data-table-hooks';
import type { RouterInputs, RouterOutputs } from '@/trpc/client';
import { useLocalStorage } from 'usehooks-ts';
import { useColumns } from './columns';
// Custom hook for persistent column visibility
const usePersistentColumnVisibility = (columns: any[]) => {
const [savedVisibility, setSavedVisibility] = useLocalStorage<
Record<string, boolean>
>('@op:sessions-table-column-visibility', {});
// Create column visibility from saved state, defaulting to true (visible)
const columnVisibility = useMemo(() => {
return columns.reduce(
(acc, column) => {
const columnId = column.id || column.accessorKey;
if (columnId) {
acc[columnId] = savedVisibility[columnId] ?? true;
}
return acc;
},
{} as Record<string, boolean>,
);
}, [columns, savedVisibility]);
const handleColumnVisibilityChange = (updater: any) => {
const newVisibility =
typeof updater === 'function' ? updater(columnVisibility) : updater;
setSavedVisibility(newVisibility);
};
return {
columnVisibility,
setColumnVisibility: handleColumnVisibilityChange,
};
};
import { FullPageEmptyState } from '@/components/full-page-empty-state';
import { Skeleton } from '@/components/skeleton';
import {
@@ -21,7 +54,7 @@ import { useWindowVirtualizer } from '@tanstack/react-virtual';
import type { TRPCInfiniteData } from '@trpc/tanstack-react-query';
import { Loader2Icon } from 'lucide-react';
import { last } from 'ramda';
import { memo, useEffect, useMemo, useRef } from 'react';
import { memo, useEffect, useMemo, useRef, useState } from 'react';
import { useInViewport } from 'react-in-viewport';
type Props = {
@@ -49,6 +82,7 @@ interface VirtualRowProps {
headerColumns: any[];
scrollMargin: number;
isLoading: boolean;
headerColumnsHash: string;
}
const VirtualRow = memo(
@@ -109,109 +143,105 @@ const VirtualRow = memo(
prevProps.virtualRow.index === nextProps.virtualRow.index &&
prevProps.virtualRow.start === nextProps.virtualRow.start &&
prevProps.virtualRow.size === nextProps.virtualRow.size &&
prevProps.isLoading === nextProps.isLoading
prevProps.isLoading === nextProps.isLoading &&
prevProps.headerColumnsHash === nextProps.headerColumnsHash
);
},
);
const VirtualizedSessionsTable = memo(
function VirtualizedSessionsTable({
table,
data,
isLoading,
}: VirtualizedSessionsTableProps) {
const parentRef = useRef<HTMLDivElement>(null);
const VirtualizedSessionsTable = ({
table,
data,
isLoading,
}: VirtualizedSessionsTableProps) => {
const parentRef = useRef<HTMLDivElement>(null);
const headerColumns = useMemo(
() =>
table.getAllLeafColumns().filter((col) => {
return table.getState().columnVisibility[col.id] !== false;
}),
[table],
);
const headerColumns = table.getAllLeafColumns().filter((col) => {
return table.getState().columnVisibility[col.id] !== false;
});
const rowVirtualizer = useWindowVirtualizer({
count: data.length,
estimateSize: () => ROW_HEIGHT, // Estimated row height
overscan: 10,
scrollMargin: parentRef.current?.offsetTop ?? 0,
});
const rowVirtualizer = useWindowVirtualizer({
count: data.length,
estimateSize: () => ROW_HEIGHT, // Estimated row height
overscan: 10,
scrollMargin: parentRef.current?.offsetTop ?? 0,
});
const virtualRows = rowVirtualizer.getVirtualItems();
const virtualRows = rowVirtualizer.getVirtualItems();
const headerColumnsHash = headerColumns.map((col) => col.id).join(',');
return (
return (
<div
ref={parentRef}
className="w-full overflow-x-auto border rounded-md bg-card"
>
{/* Table Header */}
<div
ref={parentRef}
className="w-full overflow-x-auto border rounded-md bg-card"
className="sticky top-0 z-10 bg-card border-b"
style={{
display: 'grid',
gridTemplateColumns: headerColumns
.map((col) => `${col.getSize()}px`)
.join(' '),
minWidth: 'fit-content',
}}
>
{/* Table Header */}
<div
className="sticky top-0 z-10 bg-card border-b"
style={{
display: 'grid',
gridTemplateColumns: headerColumns
.map((col) => `${col.getSize()}px`)
.join(' '),
minWidth: 'fit-content',
}}
>
{headerColumns.map((column) => {
const header = column.columnDef.header;
const width = `${column.getSize()}px`;
return (
<div
key={column.id}
className="flex items-center h-10 px-4 text-left text-[10px] uppercase text-foreground font-semibold whitespace-nowrap"
style={{
width,
}}
>
{typeof header === 'function' ? header({} as any) : header}
</div>
);
})}
</div>
{!isLoading && data.length === 0 && (
<FullPageEmptyState
title="No sessions found"
description="Looks like you haven't inserted any events yet."
/>
)}
{/* Table Body */}
<div
className="relative w-full"
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
minHeight: 'fit-content',
minWidth: 'fit-content',
}}
>
{virtualRows.map((virtualRow) => {
const row = table.getRowModel().rows[virtualRow.index];
if (!row) return null;
return (
<VirtualRow
key={virtualRow.key}
row={row}
virtualRow={{
...virtualRow,
measureElement: rowVirtualizer.measureElement,
}}
headerColumns={headerColumns}
scrollMargin={rowVirtualizer.options.scrollMargin}
isLoading={isLoading}
/>
);
})}
</div>
{headerColumns.map((column) => {
const header = column.columnDef.header;
const width = `${column.getSize()}px`;
return (
<div
key={column.id}
className="flex items-center h-10 px-4 text-left text-[10px] uppercase text-foreground font-semibold whitespace-nowrap"
style={{
width,
}}
>
{typeof header === 'function' ? header({} as any) : header}
</div>
);
})}
</div>
);
},
arePropsEqual(['data', 'isLoading']),
);
{!isLoading && data.length === 0 && (
<FullPageEmptyState
title="No sessions found"
description="Looks like you haven't inserted any events yet."
/>
)}
{/* Table Body */}
<div
className="relative w-full"
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
minHeight: 'fit-content',
minWidth: 'fit-content',
}}
>
{virtualRows.map((virtualRow) => {
const row = table.getRowModel().rows[virtualRow.index];
if (!row) return null;
return (
<VirtualRow
key={virtualRow.key}
row={row}
virtualRow={{
...virtualRow,
measureElement: rowVirtualizer.measureElement,
}}
headerColumns={headerColumns}
headerColumnsHash={headerColumnsHash}
scrollMargin={rowVirtualizer.options.scrollMargin}
isLoading={isLoading}
/>
);
})}
</div>
</div>
);
};
export const SessionsTable = ({ query }: Props) => {
const { isLoading } = query;
@@ -227,7 +257,7 @@ export const SessionsTable = ({ query }: Props) => {
// const { setPage, state: pagination } = useDataTablePagination();
const { columnVisibility, setColumnVisibility } =
useDataTableColumnVisibility(columns);
usePersistentColumnVisibility(columns);
const table = useReactTable({
data,

View File

@@ -18,6 +18,7 @@ export const InvitesTable = ({ query }: Props) => {
const columns = useColumns();
const { data, isLoading } = query;
const { table } = useTable({
name: 'invites',
columns,
data: data ?? [],
loading: isLoading,

View File

@@ -16,6 +16,7 @@ export const MembersTable = ({ query }: Props) => {
const columns = useColumns();
const { data, isLoading } = query;
const { table } = useTable({
name: 'members',
columns,
data: data ?? [],
loading: isLoading,

View File

@@ -5,6 +5,7 @@ import type {
} from '@tanstack/react-table';
import { parseAsInteger, useQueryState } from 'nuqs';
import { useState } from 'react';
import { useLocalStorage } from 'usehooks-ts';
export const useDataTablePagination = (pageSize = 10) => {
const [page, setPage] = useQueryState(
@@ -23,12 +24,29 @@ export const useDataTablePagination = (pageSize = 10) => {
export const useDataTableColumnVisibility = <TData,>(
columns: ColumnDef<TData>[],
persistentKey: string,
) => {
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(
const [columnVisibility, setColumnVisibility] = useLocalStorage<
Record<string, boolean>
>(
`@op:${persistentKey}-column-visibility`,
columns.reduce((acc, column) => {
acc[column.id!] = column.meta?.hidden ?? false;
// Use accessorKey as fallback if id is not provided
const columnId = column.id || (column as any).accessorKey;
if (columnId) {
acc[columnId] =
typeof column.meta?.hidden === 'boolean'
? !column.meta?.hidden
: true;
}
return acc;
}, {} as VisibilityState),
);
return { columnVisibility, setColumnVisibility };
const [columnOrder, setColumnOrder] = useLocalStorage<string[]>(
`@op:${persistentKey}-column-order`,
columns.map((column) => column.id!),
);
return { columnVisibility, setColumnVisibility, columnOrder, setColumnOrder };
};

View File

@@ -13,28 +13,162 @@ import {
PopoverTrigger,
} from '@/components/ui/popover';
import { cn } from '@/lib/utils';
import {
DndContext,
type DragEndEvent,
KeyboardSensor,
PointerSensor,
closestCenter,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import type { Table } from '@tanstack/react-table';
import { Check, ChevronsUpDown, Settings2Icon } from 'lucide-react';
import {
Check,
ChevronsUpDown,
GripVertical,
RotateCcw,
Settings2Icon,
} from 'lucide-react';
import * as React from 'react';
interface DataTableViewOptionsProps<TData> {
table: Table<TData>;
}
interface SortableColumnItemProps {
column: any;
onToggleVisibility: () => void;
}
function SortableColumnItem({
column,
onToggleVisibility,
}: SortableColumnItemProps) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: column.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
return (
<CommandItem
ref={setNodeRef}
style={style}
className={cn('flex items-center gap-2', isDragging && 'opacity-50')}
onSelect={onToggleVisibility}
>
<div
{...attributes}
{...listeners}
className="cursor-grab active:cursor-grabbing p-1 hover:bg-muted rounded"
>
<GripVertical className="size-3 text-muted-foreground" />
</div>
<span className="truncate flex-1">
{typeof column.columnDef.header === 'string'
? column.columnDef.header
: (column.columnDef.meta?.label ?? column.id)}
</span>
<Check
className={cn(
'ml-auto size-4 shrink-0',
column.getIsVisible() ? 'opacity-100' : 'opacity-0',
)}
/>
</CommandItem>
);
}
export function DataTableViewOptions<TData>({
table,
}: DataTableViewOptionsProps<TData>) {
const columns = React.useMemo(
() =>
table
.getAllColumns()
.filter(
(column) =>
typeof column.accessorFn !== 'undefined' && column.getCanHide(),
),
[table],
const allColumns = table.getAllColumns();
const filterableColumns = allColumns.filter(
(column) => typeof column.accessorFn !== 'undefined' && column.getCanHide(),
);
// Use the column order from the table state (managed by useDataTableColumnVisibility)
const columns = React.useMemo(() => {
const columnMap = new Map(filterableColumns.map((col) => [col.id, col]));
const orderedColumns: typeof filterableColumns = [];
const currentColumnOrder = table.getState().columnOrder;
// Add columns in the current table order
currentColumnOrder.forEach((columnId) => {
const column = columnMap.get(columnId);
if (column) {
orderedColumns.push(column);
columnMap.delete(columnId);
}
});
// Add any new columns that weren't in the current order
columnMap.forEach((column) => {
orderedColumns.push(column);
});
return orderedColumns;
}, [filterableColumns, table]);
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (active.id !== over?.id) {
const oldIndex = columns.findIndex((column) => column.id === active.id);
const newIndex = columns.findIndex((column) => column.id === over?.id);
if (oldIndex !== -1 && newIndex !== -1) {
// Reorder the columns in the table
const newColumns = [...columns];
const [removed] = newColumns.splice(oldIndex, 1);
newColumns.splice(newIndex, 0, removed);
// Update the table column order (this will automatically persist via useDataTableColumnVisibility)
table.setColumnOrder(newColumns.map((col) => col.id));
}
}
};
const handleReset = () => {
// Reset column visibility to default (all visible)
allColumns.forEach((column) => {
if (column.getCanHide()) {
column.toggleVisibility(
typeof column.columnDef.meta?.hidden === 'boolean'
? !column.columnDef.meta?.hidden
: true,
);
}
});
// Reset column order to default (this will automatically persist via useDataTableColumnVisibility)
const defaultOrder = filterableColumns.map((col) => col.id);
table.setColumnOrder(defaultOrder);
};
return (
<Popover>
<PopoverTrigger asChild>
@@ -50,32 +184,41 @@ export function DataTableViewOptions<TData>({
<ChevronsUpDown className="opacity-50 ml-2 size-4" />
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-44 p-0">
<PopoverContent align="end" className="w-52 p-0">
<Command>
<CommandInput placeholder="Search columns..." />
<CommandList>
<CommandEmpty>No columns found.</CommandEmpty>
<CommandGroup>
{columns.map((column) => (
<CommandItem
key={column.id}
onSelect={() =>
column.toggleVisibility(!column.getIsVisible())
}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={columns.map((col) => col.id)}
strategy={verticalListSortingStrategy}
>
<span className="truncate">
{typeof column.columnDef.header === 'string'
? column.columnDef.header
: (column.columnDef.meta?.label ?? column.id)}
</span>
<Check
className={cn(
'ml-auto size-4 shrink-0',
column.getIsVisible() ? 'opacity-100' : 'opacity-0',
)}
/>
</CommandItem>
))}
{columns.map((column) => (
<SortableColumnItem
key={column.id}
column={column}
onToggleVisibility={() =>
column.toggleVisibility(!column.getIsVisible())
}
/>
))}
</SortableContext>
</DndContext>
</CommandGroup>
<CommandGroup>
<CommandItem
onSelect={handleReset}
className="text-muted-foreground"
>
<RotateCcw className="size-4 mr-2" />
Reset to default
</CommandItem>
</CommandGroup>
</CommandList>
</Command>

View File

@@ -23,6 +23,7 @@ import {
useQueryStates,
} from 'nuqs';
import React, { useMemo, useState } from 'react';
import { useDataTableColumnVisibility } from './data-table-hooks';
const nuqsOptions: Options = {
shallow: true,
@@ -35,11 +36,13 @@ export function useTable<TData>({
pageSize,
data,
loading,
name,
}: {
columns: ColumnDef<TData>[];
pageSize: number;
data: TData[];
loading: boolean;
name: string;
}) {
const [page, setPage] = useQueryState(
'page',
@@ -54,6 +57,9 @@ export function useTable<TData>({
pageSize: perPage,
};
const { columnVisibility, setColumnVisibility, columnOrder, setColumnOrder } =
useDataTableColumnVisibility(columns, name);
const [columnPinning, setColumnPinning] = useState<ColumnPinningState>({
left: [
...columns
@@ -149,6 +155,9 @@ export function useTable<TData>({
};
const table = useReactTable({
meta: {
name,
},
columns,
data: useMemo(
() =>
@@ -181,7 +190,11 @@ export function useTable<TData>({
pagination,
columnPinning,
columnFilters: loading ? [] : columnFilters,
columnOrder,
columnVisibility,
},
onColumnOrderChange: setColumnOrder,
onColumnVisibilityChange: setColumnVisibility,
onColumnPinningChange: setColumnPinning,
onColumnFiltersChange: (updaterOrValue: Updater<ColumnFiltersState>) => {
setColumnFilters((prev) => {

View File

@@ -57,7 +57,12 @@ export function WidgetBody({ children, className }: WidgetBodyProps) {
export interface WidgetProps {
children: React.ReactNode;
className?: string;
ref?: React.RefObject<HTMLDivElement | null>;
}
export function Widget({ children, className }: WidgetProps) {
return <div className={cn('card self-start', className)}>{children}</div>;
export function Widget({ children, className, ...props }: WidgetProps) {
return (
<div className={cn('card self-start', className)} {...props}>
{children}
</div>
);
}

View 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],
);
}

View File

@@ -15,7 +15,11 @@ export const getIsomorphicHeaders = createIsomorphicFn()
.server(() => {
return getRequestHeaders();
})
.client(() => ({}) as Headers);
.client(() => {
const headers = new Headers();
headers.set('content-type', 'application/json');
return headers as Headers;
});
// Create a function that returns a tRPC client with optional cookies
export function createTRPCClientWithHeaders(apiUrl: string) {

View File

@@ -17,9 +17,16 @@ import FullPageLoadingState from '@/components/full-page-loading-state';
import { Providers } from '@/components/providers';
import { ThemeScriptOnce } from '@/components/theme-provider';
import { LinkButton } from '@/components/ui/button';
import { getCookiesFn } from '@/hooks/use-cookie-store';
import { useSessionExtension } from '@/hooks/use-session-extension';
import { op } from '@/utils/op';
import type { AppRouter } from '@openpanel/trpc';
import { createServerOnlyFn } from '@tanstack/react-start';
import {
getCookie,
getCookies,
getRequestHeaders,
} from '@tanstack/react-start/server';
import type { TRPCOptionsProxy } from '@trpc/tanstack-react-query';
op.init();
@@ -33,17 +40,20 @@ interface MyRouterContext {
export const Route = createRootRouteWithContext<MyRouterContext>()({
beforeLoad: async ({ context }) => {
const session = await context.queryClient.ensureQueryData(
context.trpc.auth.session.queryOptions(undefined, {
staleTime: 1000 * 60 * 5,
gcTime: 1000 * 60 * 10,
refetchOnWindowFocus: false,
refetchOnMount: false,
refetchOnReconnect: false,
}),
);
const [session, cookies] = await Promise.all([
context.queryClient.ensureQueryData(
context.trpc.auth.session.queryOptions(undefined, {
staleTime: 1000 * 60 * 5,
gcTime: 1000 * 60 * 10,
refetchOnWindowFocus: false,
refetchOnMount: false,
refetchOnReconnect: false,
}),
),
getCookiesFn(),
]);
return { session };
return { session, cookies };
},
head: () => ({
meta: [

View File

@@ -59,13 +59,15 @@ function Component() {
<PageContainer>
<PageHeader
title={
<div className="row items-center gap-4">
<div className="row items-center gap-4 min-w-0">
<ProfileAvatar {...profile.data} />
{getProfileName(profile.data, false)}
<span className="truncate">
{getProfileName(profile.data, false)}
</span>
</div>
}
>
<div className="row gap-4 mb-6">
<div className="row gap-4 mb-6 flex-wrap">
{profile.data?.properties.country && (
<div className="row gap-2 items-center">
<SerieIcon name={profile.data.properties.country} />

View File

@@ -163,6 +163,7 @@ function Component() {
const data = query.data ?? [];
const { table, loading } = useTable({
name: 'references',
columns: columnDefs,
data,
pageSize: 30,

View File

@@ -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}`);
}

View File

@@ -247,11 +247,16 @@ export class Query<T = any> {
}
// Fill
fill(from: string | Date, to: string | Date, step: string): this {
fill(
from: string | Date | Expression,
to: string | Date | Expression,
step: string | Expression,
): this {
this._fill = {
from: this.escapeDate(from),
to: this.escapeDate(to),
step: step,
from:
from instanceof Expression ? from.toString() : this.escapeDate(from),
to: to instanceof Expression ? to.toString() : this.escapeDate(to),
step: step instanceof Expression ? step.toString() : step,
};
return this;
}

View File

@@ -165,16 +165,6 @@ export class OverviewService {
views_per_session: number;
}[];
}> {
console.log('-----------------');
console.log('getMetrics', {
projectId,
filters,
startDate,
endDate,
interval,
timezone,
});
const where = this.getRawWhereClause('sessions', filters);
if (this.isPageFilter(filters)) {
// Session aggregation with bounce rates

View File

@@ -1,4 +1,7 @@
import {
TABLE_NAMES,
ch,
clix,
eventBuffer,
getChartPrevStartEndDate,
getChartStartEndDate,
@@ -14,8 +17,12 @@ import { format } from 'date-fns';
import { z } from 'zod';
import { cacheMiddleware, createTRPCRouter, publicProcedure } from '../trpc';
const cacher = cacheMiddleware((input) => {
const cacher = cacheMiddleware((input, opts) => {
const range = input.range as IChartRange;
if (opts.path === 'overview.liveData') {
return 0;
}
switch (range) {
case '30min':
case 'today':
@@ -82,6 +89,125 @@ export const overviewRouter = createTRPCRouter({
.query(async ({ input }) => {
return eventBuffer.getActiveVisitorCount(input.projectId);
}),
liveData: publicProcedure
.input(z.object({ projectId: z.string() }))
.use(cacher)
.query(async ({ input }) => {
const { timezone } = await getSettingsForProject(input.projectId);
// Get total unique sessions in the last 30 minutes
const totalSessionsQuery = clix(ch, timezone)
.select<{ total_sessions: number }>([
'uniq(session_id) as total_sessions',
])
.from(TABLE_NAMES.events)
.where('project_id', '=', input.projectId)
.where('name', '=', 'session_start')
.where('created_at', '>=', clix.exp('now() - INTERVAL 30 MINUTE'));
// Get counts per minute for the last 30 minutes
const minuteCountsQuery = clix(ch, timezone)
.select<{
minute: string;
session_count: number;
visitor_count: number;
}>([
`${clix.toStartOf('created_at', 'minute')} as minute`,
'uniq(session_id) as session_count',
'uniq(profile_id) as visitor_count',
])
.from(TABLE_NAMES.events)
.where('project_id', '=', input.projectId)
.where('name', 'IN', ['session_start', 'screen_view'])
.where('created_at', '>=', clix.exp('now() - INTERVAL 30 MINUTE'))
.groupBy(['minute'])
.orderBy('minute', 'ASC')
.fill(
clix.exp('now() - INTERVAL 30 MINUTE'),
clix.exp('now()'),
clix.exp('INTERVAL 1 MINUTE'),
);
// Get referrers per minute for the last 30 minutes
const minuteReferrersQuery = clix(ch, timezone)
.select<{
minute: string;
referrer_name: string;
count: number;
}>([
`${clix.toStartOf('created_at', 'minute')} as minute`,
'referrer_name',
'count(*) as count',
])
.from(TABLE_NAMES.events)
.where('project_id', '=', input.projectId)
.where('name', '=', 'session_start')
.where('created_at', '>=', clix.exp('now() - INTERVAL 30 MINUTE'))
.where('referrer_name', '!=', '')
.where('referrer_name', 'IS NOT NULL')
.groupBy(['minute', 'referrer_name'])
.orderBy('minute', 'ASC')
.orderBy('count', 'DESC');
// Get unique referrers in the last 30 minutes
const referrersQuery = clix(ch, timezone)
.select<{ referrer: string; count: number }>([
'referrer_name as referrer',
'count(*) as count',
])
.from(TABLE_NAMES.events)
.where('project_id', '=', input.projectId)
.where('name', '=', 'session_start')
.where('created_at', '>=', clix.exp('now() - INTERVAL 30 MINUTE'))
.where('referrer_name', '!=', '')
.where('referrer_name', 'IS NOT NULL')
.groupBy(['referrer_name'])
.orderBy('count', 'DESC')
.limit(10);
const [totalSessions, minuteCounts, minuteReferrers, referrers] =
await Promise.all([
totalSessionsQuery.execute(),
minuteCountsQuery.execute(),
minuteReferrersQuery.execute(),
referrersQuery.execute(),
]);
// Group referrers by minute
const referrersByMinute = new Map<
string,
Array<{ referrer: string; count: number }>
>();
minuteReferrers.forEach((item) => {
if (!referrersByMinute.has(item.minute)) {
referrersByMinute.set(item.minute, []);
}
referrersByMinute.get(item.minute)!.push({
referrer: item.referrer_name,
count: item.count,
});
});
return {
totalSessions: totalSessions[0]?.total_sessions || 0,
minuteCounts: minuteCounts.map((item) => ({
minute: item.minute,
sessionCount: item.session_count,
visitorCount: item.visitor_count,
timestamp: new Date(item.minute).getTime(),
time: new Date(item.minute).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
}),
referrers: referrersByMinute.get(item.minute) || [],
})),
referrers: referrers.map((item) => ({
referrer: item.referrer,
count: item.count,
})),
};
}),
stats: publicProcedure
.input(
zGetMetricsInput.omit({ startDate: true, endDate: true }).extend({

View File

@@ -169,8 +169,15 @@ const middlewareMarker = 'middlewareMarker' as 'middlewareMarker' & {
__brand: 'middlewareMarker';
};
export const cacheMiddleware = (cbOrTtl: number | ((input: any) => number)) =>
export const cacheMiddleware = (
cbOrTtl: number | ((input: any, opts: { path: string }) => number),
) =>
t.middleware(async ({ ctx, next, path, type, getRawInput, input }) => {
const ttl =
typeof cbOrTtl === 'function' ? cbOrTtl(input, { path }) : cbOrTtl;
if (!ttl) {
return next();
}
const rawInput = await getRawInput();
if (type !== 'query') {
return next();
@@ -194,7 +201,7 @@ export const cacheMiddleware = (cbOrTtl: number | ((input: any) => number)) =>
if (result.data) {
getRedisCache().setJson(
key,
typeof cbOrTtl === 'function' ? cbOrTtl(input) : cbOrTtl,
ttl,
// @ts-expect-error
result.data,
);