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 { data, isLoading } = query;
const { table } = useTable({ const { table } = useTable({
name: 'clients',
columns, columns,
data: data ?? [], data: data ?? [],
loading: isLoading, loading: isLoading,

View File

@@ -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} />;
}, },
}, },
]; ];

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 { 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>
);
}

View File

@@ -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"
/> />
), ),
}, },

View File

@@ -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,

View File

@@ -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"
/> />
), ),
}, },

View File

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

View File

@@ -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%">

View File

@@ -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>

View File

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

View File

@@ -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>

View File

@@ -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'

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

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

View File

@@ -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>

View File

@@ -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) => {

View File

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

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(() => { .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) {

View File

@@ -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: [

View File

@@ -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} />

View File

@@ -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,

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
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;
} }

View File

@@ -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

View File

@@ -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({

View File

@@ -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,
); );