fix: dashboard improvements and query speed improvements

This commit is contained in:
Carl-Gerhard Lindesvärd
2026-01-09 14:42:11 +01:00
parent 4867260ece
commit cabfb1f3f0
49 changed files with 3398 additions and 950 deletions

View File

@@ -0,0 +1,153 @@
import { useFormatDateInterval } from '@/hooks/use-format-date-interval';
import { useNumber } from '@/hooks/use-numer-formatter';
import React from 'react';
import {
ChartTooltipContainer,
ChartTooltipHeader,
ChartTooltipItem,
createChartTooltip,
} from '@/components/charts/chart-tooltip';
import type { IInterval } from '@openpanel/validation';
import { SerieIcon } from '../report-chart/common/serie-icon';
type Data = {
date: string;
timestamp: number;
[key: `${string}:sessions`]: number;
[key: `${string}:pageviews`]: number;
[key: `${string}:revenue`]: number | undefined;
[key: `${string}:payload`]: {
name: string;
prefix?: string;
color: string;
};
};
type Context = {
interval: IInterval;
};
export const OverviewLineChartTooltip = createChartTooltip<Data, Context>(
({ context: { interval }, data }) => {
const formatDate = useFormatDateInterval({
interval,
short: false,
});
const number = useNumber();
if (!data || data.length === 0) {
return null;
}
const firstItem = data[0];
// Get all payload items from the first data point
// Keys are in format "prefix:name:payload" or "name:payload"
const payloadItems = Object.keys(firstItem)
.filter((key) => key.endsWith(':payload'))
.map((key) => {
const payload = firstItem[key as keyof typeof firstItem] as {
name: string;
prefix?: string;
color: string;
};
// Extract the base key (without :payload) to access sessions/pageviews/revenue
const baseKey = key.replace(':payload', '');
return {
payload,
baseKey,
};
})
.filter(
(item) =>
item.payload &&
typeof item.payload === 'object' &&
'name' in item.payload,
);
// Sort by sessions (descending)
const sorted = payloadItems.sort((a, b) => {
const aSessions =
(firstItem[
`${a.baseKey}:sessions` as keyof typeof firstItem
] as number) ?? 0;
const bSessions =
(firstItem[
`${b.baseKey}:sessions` as keyof typeof firstItem
] as number) ?? 0;
return bSessions - aSessions;
});
const limit = 3;
const visible = sorted.slice(0, limit);
const hidden = sorted.slice(limit);
return (
<>
{visible.map((item, index) => {
const sessions =
(firstItem[
`${item.baseKey}:sessions` as keyof typeof firstItem
] as number) ?? 0;
const pageviews =
(firstItem[
`${item.baseKey}:pageviews` as keyof typeof firstItem
] as number) ?? 0;
const revenue = firstItem[
`${item.baseKey}:revenue` as keyof typeof firstItem
] as number | undefined;
return (
<React.Fragment key={item.baseKey}>
{index === 0 && firstItem.date && (
<ChartTooltipHeader>
<div>{formatDate(new Date(firstItem.date))}</div>
</ChartTooltipHeader>
)}
<ChartTooltipItem color={item.payload.color}>
<div className="flex items-center gap-1">
<SerieIcon name={item.payload.prefix || item.payload.name} />
<div className="font-medium">
{item.payload.prefix && (
<>
<span className="text-muted-foreground">
{item.payload.prefix}
</span>
<span className="mx-1">/</span>
</>
)}
{item.payload.name || 'Not set'}
</div>
</div>
<div className="col gap-1 text-sm">
{revenue !== undefined && revenue > 0 && (
<div className="flex justify-between gap-8 font-mono font-medium">
<span className="text-muted-foreground">Revenue</span>
<span style={{ color: '#3ba974' }}>
{number.currency(revenue / 100, { short: true })}
</span>
</div>
)}
<div className="flex justify-between gap-8 font-mono font-medium">
<span className="text-muted-foreground">Pageviews</span>
<span>{number.short(pageviews)}</span>
</div>
<div className="flex justify-between gap-8 font-mono font-medium">
<span className="text-muted-foreground">Sessions</span>
<span>{number.short(sessions)}</span>
</div>
</div>
</ChartTooltipItem>
</React.Fragment>
);
})}
{hidden.length > 0 && (
<div className="text-muted-foreground text-sm">
and {hidden.length} more {hidden.length === 1 ? 'item' : 'items'}
</div>
)}
</>
);
},
);

View File

@@ -0,0 +1,303 @@
import { useNumber } from '@/hooks/use-numer-formatter';
import { cn } from '@/utils/cn';
import { getChartColor } from '@/utils/theme';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
CartesianGrid,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import type { RouterOutputs } from '@/trpc/client';
import type { IInterval } from '@openpanel/validation';
import { useXAxisProps, useYAxisProps } from '../report-chart/common/axis';
import { SerieIcon } from '../report-chart/common/serie-icon';
import { OverviewLineChartTooltip } from './overview-line-chart-tooltip';
type SeriesData =
RouterOutputs['overview']['topGenericSeries']['items'][number];
interface OverviewLineChartProps {
data: RouterOutputs['overview']['topGenericSeries'];
interval: IInterval;
searchQuery?: string;
className?: string;
}
function transformDataForRecharts(
items: SeriesData[],
searchQuery?: string,
): Array<{
date: string;
timestamp: number;
[key: `${string}:sessions`]: number;
[key: `${string}:pageviews`]: number;
[key: `${string}:revenue`]: number | undefined;
[key: `${string}:payload`]: {
name: string;
prefix?: string;
color: string;
};
}> {
// Filter items by search query
const filteredItems = searchQuery
? items.filter((item) => {
const queryLower = searchQuery.toLowerCase();
return (
(item.name?.toLowerCase().includes(queryLower) ?? false) ||
(item.prefix?.toLowerCase().includes(queryLower) ?? false)
);
})
: items;
// Limit to top 15
const topItems = filteredItems.slice(0, 15);
// Get all unique dates from all items
const allDates = new Set<string>();
topItems.forEach((item) => {
item.data.forEach((d) => allDates.add(d.date));
});
const sortedDates = Array.from(allDates).sort();
// Transform to recharts format
return sortedDates.map((date) => {
const timestamp = new Date(date).getTime();
const result: Record<string, any> = {
date,
timestamp,
};
topItems.forEach((item, index) => {
const dataPoint = item.data.find((d) => d.date === date);
if (dataPoint) {
// Use prefix:name as key to avoid collisions when same name exists with different prefixes
const key = item.prefix ? `${item.prefix}:${item.name}` : item.name;
result[`${key}:sessions`] = dataPoint.sessions;
result[`${key}:pageviews`] = dataPoint.pageviews;
if (dataPoint.revenue !== undefined) {
result[`${key}:revenue`] = dataPoint.revenue;
}
result[`${key}:payload`] = {
name: item.name,
prefix: item.prefix,
color: getChartColor(index),
};
}
});
return result as typeof result & {
date: string;
timestamp: number;
};
});
}
export function OverviewLineChart({
data,
interval,
searchQuery,
className,
}: OverviewLineChartProps) {
const number = useNumber();
const chartData = useMemo(
() => transformDataForRecharts(data.items, searchQuery),
[data.items, searchQuery],
);
const visibleItems = useMemo(() => {
const filtered = searchQuery
? data.items.filter((item) => {
const queryLower = searchQuery.toLowerCase();
return (
(item.name?.toLowerCase().includes(queryLower) ?? false) ||
(item.prefix?.toLowerCase().includes(queryLower) ?? false)
);
})
: data.items;
return filtered.slice(0, 15);
}, [data.items, searchQuery]);
const xAxisProps = useXAxisProps({ interval, hide: false });
const yAxisProps = useYAxisProps({});
if (visibleItems.length === 0) {
return (
<div
className={cn('flex items-center justify-center h-[358px]', className)}
>
<div className="text-muted-foreground text-sm">
{searchQuery ? 'No results found' : 'No data available'}
</div>
</div>
);
}
return (
<div className={cn('w-full p-4', className)}>
<div className="h-[358px] w-full">
<OverviewLineChartTooltip.TooltipProvider interval={interval}>
<ResponsiveContainer>
<LineChart data={chartData}>
<CartesianGrid
strokeDasharray="3 3"
horizontal={true}
vertical={false}
className="stroke-border"
/>
<XAxis {...xAxisProps} />
<YAxis {...yAxisProps} />
<Tooltip content={<OverviewLineChartTooltip.Tooltip />} />
{visibleItems.map((item, index) => {
const color = getChartColor(index);
// Use prefix:name as key to avoid collisions when same name exists with different prefixes
const key = item.prefix
? `${item.prefix}:${item.name}`
: item.name;
return (
<Line
key={key}
type="monotone"
dataKey={`${key}:sessions`}
stroke={color}
strokeWidth={2}
dot={false}
isAnimationActive={false}
/>
);
})}
</LineChart>
</ResponsiveContainer>
</OverviewLineChartTooltip.TooltipProvider>
</div>
{/* Legend */}
<LegendScrollable items={visibleItems} />
</div>
);
}
function LegendScrollable({
items,
}: {
items: SeriesData[];
}) {
const scrollRef = useRef<HTMLDivElement>(null);
const [showLeftGradient, setShowLeftGradient] = useState(false);
const [showRightGradient, setShowRightGradient] = useState(false);
const updateGradients = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
const { scrollLeft, scrollWidth, clientWidth } = el;
const hasOverflow = scrollWidth > clientWidth;
setShowLeftGradient(hasOverflow && scrollLeft > 0);
setShowRightGradient(
hasOverflow && scrollLeft < scrollWidth - clientWidth - 1,
);
}, []);
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
updateGradients();
el.addEventListener('scroll', updateGradients);
window.addEventListener('resize', updateGradients);
return () => {
el.removeEventListener('scroll', updateGradients);
window.removeEventListener('resize', updateGradients);
};
}, [updateGradients]);
// Update gradients when items change
useEffect(() => {
requestAnimationFrame(updateGradients);
}, [items, updateGradients]);
return (
<div className="relative mt-4 -mb-2">
{/* Left gradient */}
<div
className={cn(
'pointer-events-none absolute left-0 top-0 z-10 h-full w-8 bg-gradient-to-r from-card to-transparent transition-opacity duration-200',
showLeftGradient ? 'opacity-100' : 'opacity-0',
)}
/>
{/* Scrollable legend */}
<div
ref={scrollRef}
className="flex gap-x-4 gap-y-1 overflow-x-auto px-2 py-1 hide-scrollbar text-xs"
>
{items.map((item, index) => {
const color = getChartColor(index);
return (
<div
className="flex shrink-0 items-center gap-1"
key={item.prefix ? `${item.prefix}:${item.name}` : item.name}
style={{ color }}
>
<SerieIcon name={item.prefix || item.name} />
<span className="font-semibold whitespace-nowrap">
{item.prefix && (
<>
<span className="text-muted-foreground">{item.prefix}</span>
<span className="mx-1">/</span>
</>
)}
{item.name || 'Not set'}
</span>
</div>
);
})}
</div>
{/* Right gradient */}
<div
className={cn(
'pointer-events-none absolute right-0 top-0 z-10 h-full w-8 bg-gradient-to-l from-card to-transparent transition-opacity duration-200',
showRightGradient ? 'opacity-100' : 'opacity-0',
)}
/>
</div>
);
}
export function OverviewLineChartLoading({
className,
}: {
className?: string;
}) {
return (
<div
className={cn('flex items-center justify-center h-[358px]', className)}
>
<div className="text-muted-foreground text-sm">Loading...</div>
</div>
);
}
export function OverviewLineChartEmpty({
className,
}: {
className?: string;
}) {
return (
<div
className={cn('flex items-center justify-center h-[358px]', className)}
>
<div className="text-muted-foreground text-sm">No data available</div>
</div>
);
}

View File

@@ -0,0 +1,264 @@
import { useNumber } from '@/hooks/use-numer-formatter';
import { ModalContent } from '@/modals/Modal/Container';
import { cn } from '@/utils/cn';
import { DialogTitle } from '@radix-ui/react-dialog';
import { useVirtualizer } from '@tanstack/react-virtual';
import { SearchIcon } from 'lucide-react';
import React, { useMemo, useRef, useState } from 'react';
import { Input } from '../ui/input';
const ROW_HEIGHT = 36;
// Revenue pie chart component
function RevenuePieChart({ percentage }: { percentage: number }) {
const size = 16;
const strokeWidth = 2;
const radius = (size - strokeWidth) / 2;
const circumference = 2 * Math.PI * radius;
const offset = circumference - percentage * circumference;
return (
<svg width={size} height={size} className="flex-shrink-0">
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="currentColor"
strokeWidth={strokeWidth}
className="text-def-200"
/>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="#3ba974"
strokeWidth={strokeWidth}
strokeDasharray={circumference}
strokeDashoffset={offset}
strokeLinecap="round"
transform={`rotate(-90 ${size / 2} ${size / 2})`}
className="transition-all"
/>
</svg>
);
}
// Base data type that all items must conform to
export interface OverviewListItem {
sessions: number;
pageviews: number;
revenue?: number;
}
interface OverviewListModalProps<T extends OverviewListItem> {
/** Modal title */
title: string;
/** Search placeholder text */
searchPlaceholder?: string;
/** The data to display */
data: T[];
/** Extract a unique key for each item */
keyExtractor: (item: T) => string;
/** Filter function for search - receives item and lowercase search query */
searchFilter: (item: T, query: string) => boolean;
/** Render the main content cell (first column) */
renderItem: (item: T) => React.ReactNode;
/** Optional footer content */
footer?: React.ReactNode;
/** Optional header content (appears below title/search) */
headerContent?: React.ReactNode;
/** Column name for the first column */
columnName?: string;
/** Whether to show pageviews column */
showPageviews?: boolean;
/** Whether to show sessions column */
showSessions?: boolean;
}
export function OverviewListModal<T extends OverviewListItem>({
title,
searchPlaceholder = 'Search...',
data,
keyExtractor,
searchFilter,
renderItem,
footer,
headerContent,
columnName = 'Name',
showPageviews = true,
showSessions = true,
}: OverviewListModalProps<T>) {
const [searchQuery, setSearchQuery] = useState('');
const scrollAreaRef = useRef<HTMLDivElement>(null);
const number = useNumber();
// Filter data based on search query
const filteredData = useMemo(() => {
if (!searchQuery.trim()) {
return data;
}
const queryLower = searchQuery.toLowerCase();
return data.filter((item) => searchFilter(item, queryLower));
}, [data, searchQuery, searchFilter]);
// Calculate totals and check for revenue
const { maxSessions, totalRevenue, hasRevenue, hasPageviews } =
useMemo(() => {
const maxSessions = Math.max(...filteredData.map((item) => item.sessions));
const totalRevenue = filteredData.reduce(
(sum, item) => sum + (item.revenue ?? 0),
0,
);
const hasRevenue = filteredData.some((item) => (item.revenue ?? 0) > 0);
const hasPageviews =
showPageviews && filteredData.some((item) => item.pageviews > 0);
return { maxSessions, totalRevenue, hasRevenue, hasPageviews };
}, [filteredData, showPageviews]);
// Virtual list setup
const virtualizer = useVirtualizer({
count: filteredData.length,
getScrollElement: () => scrollAreaRef.current,
estimateSize: () => ROW_HEIGHT,
overscan: 10,
});
const virtualItems = virtualizer.getVirtualItems();
return (
<ModalContent className="flex !max-h-[90vh] flex-col p-0 gap-0 sm:max-w-2xl">
{/* Sticky Header */}
<div className="flex-shrink-0 border-b border-border">
<div className="p-6 pb-4">
<DialogTitle className="text-lg font-semibold mb-4">
{title}
</DialogTitle>
<div className="relative">
<SearchIcon className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
type="search"
placeholder={searchPlaceholder}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
{headerContent}
</div>
{/* Column Headers */}
<div
className="grid px-4 py-2 text-sm font-medium text-muted-foreground bg-def-100"
style={{
gridTemplateColumns: `1fr ${hasRevenue ? '100px' : ''} ${hasPageviews ? '80px' : ''} ${showSessions ? '80px' : ''}`.trim(),
}}
>
<div className="text-left truncate">{columnName}</div>
{hasRevenue && <div className="text-right">Revenue</div>}
{hasPageviews && <div className="text-right">Views</div>}
{showSessions && <div className="text-right">Sessions</div>}
</div>
</div>
{/* Virtualized Scrollable Body */}
<div
ref={scrollAreaRef}
className="flex-1 min-h-0 overflow-y-auto"
style={{ maxHeight: '60vh' }}
>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{virtualItems.map((virtualRow) => {
const item = filteredData[virtualRow.index];
if (!item) return null;
const percentage = item.sessions / maxSessions;
const revenuePercentage =
totalRevenue > 0 ? (item.revenue ?? 0) / totalRevenue : 0;
return (
<div
key={keyExtractor(item)}
className="absolute top-0 left-0 w-full group/row"
style={{
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
>
{/* Background bar */}
<div className="absolute inset-0 overflow-hidden">
<div
className="h-full bg-def-200 group-hover/row:bg-blue-200 dark:group-hover/row:bg-blue-900 transition-colors"
style={{ width: `${percentage * 100}%` }}
/>
</div>
{/* Row content */}
<div
className="relative grid h-full items-center px-4 border-b border-border"
style={{
gridTemplateColumns: `1fr ${hasRevenue ? '100px' : ''} ${hasPageviews ? '80px' : ''} ${showSessions ? '80px' : ''}`.trim(),
}}
>
{/* Main content cell */}
<div className="min-w-0 truncate pr-2">{renderItem(item)}</div>
{/* Revenue cell */}
{hasRevenue && (
<div className="flex items-center justify-end gap-2">
<span
className="font-semibold font-mono text-sm"
style={{ color: '#3ba974' }}
>
{(item.revenue ?? 0) > 0
? number.currency((item.revenue ?? 0) / 100, {
short: true,
})
: '-'}
</span>
<RevenuePieChart percentage={revenuePercentage} />
</div>
)}
{/* Pageviews cell */}
{hasPageviews && (
<div className="text-right font-semibold font-mono text-sm">
{number.short(item.pageviews)}
</div>
)}
{/* Sessions cell */}
{showSessions && (
<div className="text-right font-semibold font-mono text-sm">
{number.short(item.sessions)}
</div>
)}
</div>
</div>
);
})}
</div>
{/* Empty state */}
{filteredData.length === 0 && (
<div className="flex items-center justify-center h-32 text-muted-foreground">
{searchQuery ? 'No results found' : 'No data available'}
</div>
)}
</div>
{/* Fixed Footer */}
{footer && (
<div className="flex-shrink-0 border-t border-border p-4">{footer}</div>
)}
</ModalContent>
);
}

View File

@@ -1,5 +1,4 @@
import { useTRPC } from '@/integrations/trpc/react';
import { cn } from '@/utils/cn';
import { useQuery } from '@tanstack/react-query';
import { useNumber } from '@/hooks/use-numer-formatter';
@@ -8,18 +7,14 @@ import * as Portal from '@radix-ui/react-portal';
import { bind } from 'bind-event-listener';
import throttle from 'lodash.throttle';
import React, { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import {
Bar,
BarChart,
CartesianGrid,
Customized,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import { BarShapeBlue } from '../charts/common-bar';
import { SerieIcon } from '../report-chart/common/serie-icon';
interface OverviewLiveHistogramProps {
projectId: string;
@@ -86,10 +81,8 @@ export function OverviewLiveHistogram({
<YAxis hide domain={[0, maxDomain]} />
<Bar
dataKey="sessionCount"
fill="rgba(59, 121, 255, 0.2)"
className="fill-chart-0"
isAnimationActive={false}
shape={BarShapeBlue}
activeBar={BarShapeBlue}
/>
</BarChart>
</ResponsiveContainer>

View File

@@ -1,7 +1,7 @@
import { fancyMinutes, useNumber } from '@/hooks/use-numer-formatter';
import { cn } from '@/utils/cn';
import AutoSizer from 'react-virtualized-auto-sizer';
import { Area, AreaChart, Tooltip } from 'recharts';
import { Area, AreaChart, Bar, BarChart, Tooltip } from 'recharts';
import { formatDate, timeAgo } from '@/utils/date';
import { getChartColor } from '@/utils/theme';
@@ -144,51 +144,33 @@ export function OverviewMetricCard({
<div className={cn('group relative p-4')}>
<div
className={cn(
'absolute -left-1 -right-1 bottom-0 top-0 z-0 opacity-50 transition-opacity duration-300 group-hover:opacity-100',
'absolute left-4 right-4 bottom-0 z-0 opacity-50 transition-opacity duration-300 group-hover:opacity-100',
)}
>
<AutoSizer>
{({ width, height }) => (
<AreaChart
<AutoSizer style={{ height: 20 }}>
{({ width }) => (
<BarChart
width={width}
height={height / 4}
height={20}
data={data}
style={{ marginTop: (height / 4) * 3, background: 'transparent' }}
style={{
background: 'transparent',
}}
onMouseMove={(event) => {
setCurrentIndex(event.activeTooltipIndex ?? null);
}}
margin={{ top: 0, right: 0, left: 0, bottom: 0 }}
>
<defs>
<linearGradient
id={`colorUv${id}`}
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="0%"
stopColor={graphColors}
stopOpacity={0.2}
/>
<stop
offset="100%"
stopColor={graphColors}
stopOpacity={0.05}
/>
</linearGradient>
</defs>
<Tooltip content={() => null} />
<Area
<Tooltip content={() => null} cursor={false} />
<Bar
dataKey={'current'}
type="step"
fill={`url(#colorUv${id})`}
fill={graphColors}
fillOpacity={1}
stroke={graphColors}
strokeWidth={1}
strokeWidth={0}
isAnimationActive={false}
/>
</AreaChart>
</BarChart>
)}
</AutoSizer>
</div>
@@ -225,13 +207,11 @@ export function OverviewMetricCardNumber({
isLoading?: boolean;
}) {
return (
<div className={cn('flex min-w-0 flex-col gap-2', className)}>
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2 text-left">
<span className="truncate text-sm font-medium text-muted-foreground leading-[1.1]">
{label}
</span>
</div>
<div className={cn('min-w-0 col gap-2 items-start', className)}>
<div className="flex min-w-0 items-center gap-2 text-left">
<span className="truncate text-sm font-medium text-muted-foreground leading-[1.1]">
{label}
</span>
</div>
{isLoading ? (
<div className="flex items-end justify-between gap-4">
@@ -239,13 +219,13 @@ export function OverviewMetricCardNumber({
<Skeleton className="h-6 w-12" />
</div>
) : (
<div className="flex items-end justify-between gap-4">
<div className="truncate font-mono text-3xl leading-[1.1] font-bold">
{value}
</div>
{enhancer}
<div className="truncate font-mono text-3xl leading-[1.1] font-bold">
{value}
</div>
)}
<div className="absolute right-0 top-0 bottom-0 center justify-center col pr-4">
{enhancer}
</div>
</div>
);
}

View File

@@ -1,11 +1,9 @@
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
import { cn } from '@/utils/cn';
import { useState } from 'react';
import { useMemo, useState } from 'react';
import { NOT_SET_VALUE } from '@openpanel/constants';
import type { IChartType } from '@openpanel/validation';
import { useNumber } from '@/hooks/use-numer-formatter';
import { useTRPC } from '@/integrations/trpc/react';
import { pushModal } from '@/modals';
import { useQuery } from '@tanstack/react-query';
@@ -13,7 +11,12 @@ import { SerieIcon } from '../report-chart/common/serie-icon';
import { Widget, WidgetBody } from '../widget';
import { OVERVIEW_COLUMNS_NAME } from './overview-constants';
import OverviewDetailsButton from './overview-details-button';
import { WidgetButtons, WidgetFooter, WidgetHead } from './overview-widget';
import {
OverviewLineChart,
OverviewLineChartLoading,
} from './overview-line-chart';
import { OverviewViewToggle, useOverviewView } from './overview-view-toggle';
import { WidgetFooter, WidgetHeadSearchable } from './overview-widget';
import {
OverviewWidgetTableGeneric,
OverviewWidgetTableLoading,
@@ -31,6 +34,7 @@ export default function OverviewTopDevices({
useOverviewOptions();
const [filters, setFilter] = useEventQueryFilters();
const [chartType] = useState<IChartType>('bar');
const [searchQuery, setSearchQuery] = useState('');
const isPageFilter = filters.find((filter) => filter.name === 'path');
const [widget, setWidget, widgets] = useOverviewWidget('tech', {
device: {
@@ -316,6 +320,7 @@ export default function OverviewTopDevices({
});
const trpc = useTRPC();
const [view] = useOverviewView();
const query = useQuery(
trpc.overview.topGeneric.queryOptions({
@@ -328,31 +333,67 @@ export default function OverviewTopDevices({
}),
);
const seriesQuery = useQuery(
trpc.overview.topGenericSeries.queryOptions(
{
projectId,
range,
filters,
column: widget.key,
startDate,
endDate,
interval,
},
{
enabled: view === 'chart',
},
),
);
const filteredData = useMemo(() => {
const data = (query.data ?? []).slice(0, 15);
if (!searchQuery.trim()) {
return data;
}
const queryLower = searchQuery.toLowerCase();
return data.filter((item) => item.name?.toLowerCase().includes(queryLower));
}, [query.data, searchQuery]);
const tabs = widgets.map((w) => ({
key: w.key,
label: w.btn,
}));
return (
<>
<Widget className="col-span-6 md:col-span-3">
<WidgetHead>
<div className="title">{widget.title}</div>
<WidgetButtons>
{widgets.map((w) => (
<button
type="button"
key={w.key}
onClick={() => setWidget(w.key)}
className={cn(w.key === widget.key && 'active')}
>
{w.btn}
</button>
))}
</WidgetButtons>
</WidgetHead>
<WidgetHeadSearchable
tabs={tabs}
activeTab={widget.key}
onTabChange={setWidget}
searchValue={searchQuery}
onSearchChange={setSearchQuery}
searchPlaceholder={`Search ${widget.btn.toLowerCase()}`}
className="border-b-0 pb-2"
/>
<WidgetBody className="p-0">
{query.isLoading ? (
{view === 'chart' ? (
seriesQuery.isLoading ? (
<OverviewLineChartLoading />
) : seriesQuery.data ? (
<OverviewLineChart
data={seriesQuery.data}
interval={interval}
searchQuery={searchQuery}
/>
) : (
<OverviewLineChartLoading />
)
) : query.isLoading ? (
<OverviewWidgetTableLoading />
) : (
<OverviewWidgetTableGeneric
data={query.data ?? []}
data={filteredData}
column={{
name: OVERVIEW_COLUMNS_NAME[widget.key],
render(item) {
@@ -384,7 +425,8 @@ export default function OverviewTopDevices({
})
}
/>
{/* <OverviewChartToggle {...{ chartType, setChartType }} /> */}
<div className="flex-1" />
<OverviewViewToggle />
</WidgetFooter>
</Widget>
</>

View File

@@ -1,225 +1,174 @@
import { ReportChart } from '@/components/report-chart';
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
import { cn } from '@/utils/cn';
import { useState } from 'react';
import { useMemo, useState } from 'react';
import type { IChartType } from '@openpanel/validation';
import type { IChartInput } from '@openpanel/validation';
import { useTRPC } from '@/integrations/trpc/react';
import { useQuery } from '@tanstack/react-query';
import { Widget, WidgetBody } from '../widget';
import { OverviewChartToggle } from './overview-chart-toggle';
import { WidgetButtons, WidgetFooter, WidgetHead } from './overview-widget';
import { WidgetFooter, WidgetHeadSearchable } from './overview-widget';
import {
type EventTableItem,
OverviewWidgetTableEvents,
OverviewWidgetTableLoading,
} from './overview-widget-table';
import { useOverviewOptions } from './useOverviewOptions';
import { useOverviewWidget } from './useOverviewWidget';
import { useOverviewWidgetV2 } from './useOverviewWidget';
export interface OverviewTopEventsProps {
projectId: string;
}
export default function OverviewTopEvents({
projectId,
}: OverviewTopEventsProps) {
const { interval, range, previous, startDate, endDate } =
useOverviewOptions();
const [filters] = useEventQueryFilters();
const [filters, setFilter] = useEventQueryFilters();
const trpc = useTRPC();
const { data: conversions } = useQuery(
trpc.event.conversionNames.queryOptions({ projectId }),
);
const [chartType, setChartType] = useState<IChartType>('bar');
const [widget, setWidget, widgets] = useOverviewWidget('ev', {
const [searchQuery, setSearchQuery] = useState('');
const [widget, setWidget, widgets] = useOverviewWidgetV2('ev', {
your: {
title: 'Top events',
btn: 'Your',
chart: {
report: {
limit: 10,
projectId,
startDate,
endDate,
series: [
{
type: 'event',
segment: 'event',
filters: [
...filters,
{
id: 'ex_session',
name: 'name',
operator: 'isNot',
value: ['session_start', 'session_end', 'screen_view'],
},
],
id: 'A',
name: '*',
},
],
breakdowns: [
{
id: 'A',
name: 'name',
},
],
chartType,
lineType: 'monotone',
interval: interval,
name: 'Your top events',
range: range,
previous: previous,
metric: 'sum',
},
},
},
all: {
title: 'Top events',
btn: 'All',
chart: {
report: {
limit: 10,
projectId,
startDate,
endDate,
series: [
{
type: 'event',
segment: 'event',
filters: [...filters],
id: 'A',
name: '*',
},
],
breakdowns: [
{
id: 'A',
name: 'name',
},
],
chartType,
lineType: 'monotone',
interval: interval,
name: 'All top events',
range: range,
previous: previous,
metric: 'sum',
},
title: 'Events',
btn: 'Events',
meta: {
filters: [
{
id: 'ex_session',
name: 'name',
operator: 'isNot',
value: ['session_start', 'session_end', 'screen_view'],
},
],
eventName: '*',
},
},
conversions: {
title: 'Conversions',
btn: 'Conversions',
hide: !conversions || conversions.length === 0,
chart: {
report: {
limit: 10,
projectId,
startDate,
endDate,
series: [
{
type: 'event',
segment: 'event',
filters: [
...filters,
{
id: 'conversion',
name: 'name',
operator: 'is',
value: conversions?.map((c) => c.name) ?? [],
},
],
id: 'A',
name: '*',
},
],
breakdowns: [
{
id: 'A',
name: 'name',
},
],
chartType,
lineType: 'monotone',
interval: interval,
name: 'Conversions',
range: range,
previous: previous,
metric: 'sum',
},
meta: {
filters: [
{
id: 'conversion',
name: 'name',
operator: 'is',
value: conversions?.map((c) => c.name) ?? [],
},
],
eventName: '*',
},
},
link_out: {
title: 'Link out',
btn: 'Link out',
chart: {
report: {
limit: 10,
projectId,
startDate,
endDate,
series: [
{
type: 'event',
segment: 'event',
id: 'A',
name: 'link_out',
filters: [],
},
],
breakdowns: [
{
id: 'A',
name: 'properties.href',
},
],
chartType,
lineType: 'monotone',
interval: interval,
name: 'Link out',
range: range,
previous: previous,
metric: 'sum',
},
meta: {
filters: [],
eventName: 'link_out',
breakdownProperty: 'properties.href',
},
},
});
const report: IChartInput = useMemo(
() => ({
limit: 1000,
projectId,
startDate,
endDate,
series: [
{
type: 'event' as const,
segment: 'event' as const,
filters: [...filters, ...(widget.meta?.filters ?? [])],
id: 'A',
name: widget.meta?.eventName ?? '*',
},
],
breakdowns: [
{
id: 'A',
name: widget.meta?.breakdownProperty ?? 'name',
},
],
chartType: 'bar' as const,
lineType: 'monotone' as const,
interval,
name: widget.title,
range,
previous,
metric: 'sum' as const,
}),
[projectId, startDate, endDate, filters, widget, interval, range, previous],
);
const query = useQuery(trpc.chart.aggregate.queryOptions(report));
const tableData: EventTableItem[] = useMemo(() => {
if (!query.data?.series) return [];
return query.data.series.map((serie) => ({
id: serie.id,
name: serie.names[serie.names.length - 1] ?? serie.names[0] ?? '',
count: serie.metrics.sum,
}));
}, [query.data]);
const filteredData = useMemo(() => {
if (!searchQuery.trim()) {
return tableData.slice(0, 15);
}
const queryLower = searchQuery.toLowerCase();
return tableData
.filter((item) => item.name?.toLowerCase().includes(queryLower))
.slice(0, 15);
}, [tableData, searchQuery]);
const tabs = useMemo(
() =>
widgets
.filter((item) => item.hide !== true)
.map((w) => ({
key: w.key,
label: w.btn,
})),
[widgets],
);
return (
<>
<Widget className="col-span-6 md:col-span-3">
<WidgetHead>
<div className="title">{widget.title}</div>
<WidgetButtons>
{widgets
.filter((item) => item.hide !== true)
.map((w) => (
<button
type="button"
key={w.key}
onClick={() => setWidget(w.key)}
className={cn(w.key === widget.key && 'active')}
>
{w.btn}
</button>
))}
</WidgetButtons>
</WidgetHead>
<WidgetBody className="p-3">
<ReportChart
options={{
hideID: true,
columns: ['Event'],
renderSerieName(names) {
return names[1];
},
}}
report={{
...widget.chart.report,
previous: false,
}}
/>
<WidgetHeadSearchable
tabs={tabs}
activeTab={widget.key}
onTabChange={setWidget}
searchValue={searchQuery}
onSearchChange={setSearchQuery}
searchPlaceholder={`Search ${widget.btn.toLowerCase()}`}
className="border-b-0 pb-2"
/>
<WidgetBody className="p-0">
{query.isLoading ? (
<OverviewWidgetTableLoading />
) : (
<OverviewWidgetTableEvents
data={filteredData}
onItemClick={(name) => {
if (widget.meta?.breakdownProperty) {
setFilter(widget.meta.breakdownProperty, name);
} else {
setFilter('name', name);
}
}}
/>
)}
</WidgetBody>
<WidgetFooter>
<OverviewChartToggle {...{ chartType, setChartType }} />
<div className="flex-1" />
</WidgetFooter>
</Widget>
</>

View File

@@ -1,18 +1,15 @@
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
import { useTRPC } from '@/integrations/trpc/react';
import { ModalContent, ModalHeader } from '@/modals/Modal/Container';
import type { IGetTopGenericInput } from '@openpanel/db';
import { useInfiniteQuery } from '@tanstack/react-query';
import { useQuery } from '@tanstack/react-query';
import { ChevronRightIcon } from 'lucide-react';
import { SerieIcon } from '../report-chart/common/serie-icon';
import { Button } from '../ui/button';
import { ScrollArea } from '../ui/scroll-area';
import {
OVERVIEW_COLUMNS_NAME,
OVERVIEW_COLUMNS_NAME_PLURAL,
} from './overview-constants';
import { OverviewWidgetTableGeneric } from './overview-widget-table';
import { OverviewListModal } from './overview-list-modal';
import { useOverviewOptions } from './useOverviewOptions';
interface OverviewTopGenericModalProps {
@@ -24,83 +21,55 @@ export default function OverviewTopGenericModal({
projectId,
column,
}: OverviewTopGenericModalProps) {
const [filters, setFilter] = useEventQueryFilters();
const [_filters, setFilter] = useEventQueryFilters();
const { startDate, endDate, range } = useOverviewOptions();
const trpc = useTRPC();
const query = useInfiniteQuery(
trpc.overview.topGeneric.infiniteQueryOptions(
{
projectId,
filters,
startDate,
endDate,
range,
limit: 50,
column,
},
{
getNextPageParam: (lastPage, pages) => {
if (lastPage.length === 0) {
return null;
}
return pages.length + 1;
},
},
),
const query = useQuery(
trpc.overview.topGeneric.queryOptions({
projectId,
filters: _filters,
startDate,
endDate,
range,
column,
}),
);
const data = query.data?.pages.flat() || [];
const isEmpty = !query.hasNextPage && !query.isFetching;
const columnNamePlural = OVERVIEW_COLUMNS_NAME_PLURAL[column];
const columnName = OVERVIEW_COLUMNS_NAME[column];
return (
<ModalContent>
<ModalHeader title={`Top ${columnNamePlural}`} />
<ScrollArea className="-mx-6 px-2">
<OverviewWidgetTableGeneric
data={data}
column={{
name: columnName,
render(item) {
return (
<div className="row items-center gap-2 min-w-0 relative">
<SerieIcon name={item.prefix || item.name} />
<button
type="button"
className="truncate"
onClick={() => {
setFilter(column, item.name);
}}
>
{item.prefix && (
<span className="mr-1 row inline-flex items-center gap-1">
<span>{item.prefix}</span>
<span>
<ChevronRightIcon className="size-3" />
</span>
</span>
)}
{item.name || 'Not set'}
</button>
</div>
);
},
}}
/>
<div className="row center-center p-4 pb-0">
<Button
variant="outline"
className="w-full"
onClick={() => query.fetchNextPage()}
disabled={isEmpty}
<OverviewListModal
title={`Top ${columnNamePlural}`}
searchPlaceholder={`Search ${columnNamePlural.toLowerCase()}...`}
data={query.data ?? []}
keyExtractor={(item) => (item.prefix ?? '') + item.name}
searchFilter={(item, query) =>
item.name?.toLowerCase().includes(query) ||
item.prefix?.toLowerCase().includes(query) ||
false
}
columnName={columnName}
renderItem={(item) => (
<div className="flex items-center gap-2 min-w-0">
<SerieIcon name={item.prefix || item.name} />
<button
type="button"
className="truncate hover:underline"
onClick={() => {
setFilter(column, item.name);
}}
>
Load more
</Button>
{item.prefix && (
<span className="mr-1 inline-flex items-center gap-1">
<span>{item.prefix}</span>
<ChevronRightIcon className="size-3" />
</span>
)}
{item.name || 'Not set'}
</button>
</div>
</ScrollArea>
</ModalContent>
)}
/>
);
}

View File

@@ -1,10 +1,8 @@
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
import { cn } from '@/utils/cn';
import { useState } from 'react';
import { useMemo, useState } from 'react';
import type { IChartType } from '@openpanel/validation';
import { useNumber } from '@/hooks/use-numer-formatter';
import { useTRPC } from '@/integrations/trpc/react';
import { pushModal } from '@/modals';
import { countries } from '@/translations/countries';
@@ -16,7 +14,16 @@ import { SerieIcon } from '../report-chart/common/serie-icon';
import { Widget, WidgetBody } from '../widget';
import { OVERVIEW_COLUMNS_NAME } from './overview-constants';
import OverviewDetailsButton from './overview-details-button';
import { WidgetButtons, WidgetFooter, WidgetHead } from './overview-widget';
import {
OverviewLineChart,
OverviewLineChartLoading,
} from './overview-line-chart';
import { OverviewViewToggle, useOverviewView } from './overview-view-toggle';
import {
WidgetFooter,
WidgetHead,
WidgetHeadSearchable,
} from './overview-widget';
import {
OverviewWidgetTableGeneric,
OverviewWidgetTableLoading,
@@ -32,6 +39,7 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
useOverviewOptions();
const [chartType, setChartType] = useState<IChartType>('bar');
const [filters, setFilter] = useEventQueryFilters();
const [searchQuery, setSearchQuery] = useState('');
const isPageFilter = filters.find((filter) => filter.name === 'path');
const [widget, setWidget, widgets] = useOverviewWidgetV2('geo', {
country: {
@@ -48,8 +56,8 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
},
});
const number = useNumber();
const trpc = useTRPC();
const [view] = useOverviewView();
const query = useQuery(
trpc.overview.topGeneric.queryOptions({
@@ -62,31 +70,74 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
}),
);
const seriesQuery = useQuery(
trpc.overview.topGenericSeries.queryOptions(
{
projectId,
range,
filters,
column: widget.key,
startDate,
endDate,
interval,
},
{
enabled: view === 'chart',
},
),
);
const filteredData = useMemo(() => {
const data = (query.data ?? []).slice(0, 15);
if (!searchQuery.trim()) {
return data;
}
const queryLower = searchQuery.toLowerCase();
return data.filter(
(item) =>
item.name?.toLowerCase().includes(queryLower) ||
item.prefix?.toLowerCase().includes(queryLower) ||
countries[item.name as keyof typeof countries]
?.toLowerCase()
.includes(queryLower),
);
}, [query.data, searchQuery]);
const tabs = widgets.map((w) => ({
key: w.key,
label: w.btn,
}));
return (
<>
<Widget className="col-span-6 md:col-span-3">
<WidgetHead>
<div className="title">{widget.title}</div>
<WidgetButtons>
{widgets.map((w) => (
<button
type="button"
key={w.key}
onClick={() => setWidget(w.key)}
className={cn(w.key === widget.key && 'active')}
>
{w.btn}
</button>
))}
</WidgetButtons>
</WidgetHead>
<WidgetHeadSearchable
tabs={tabs}
activeTab={widget.key}
onTabChange={setWidget}
searchValue={searchQuery}
onSearchChange={setSearchQuery}
searchPlaceholder={`Search ${widget.btn.toLowerCase()}`}
className="border-b-0 pb-2"
/>
<WidgetBody className="p-0">
{query.isLoading ? (
{view === 'chart' ? (
seriesQuery.isLoading ? (
<OverviewLineChartLoading />
) : seriesQuery.data ? (
<OverviewLineChart
data={seriesQuery.data}
interval={interval}
searchQuery={searchQuery}
/>
) : (
<OverviewLineChartLoading />
)
) : query.isLoading ? (
<OverviewWidgetTableLoading />
) : (
<OverviewWidgetTableGeneric
data={query.data ?? []}
data={filteredData}
column={{
name: OVERVIEW_COLUMNS_NAME[widget.key],
render(item) {
@@ -139,8 +190,9 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
})
}
/>
{/* <OverviewChartToggle {...{ chartType, setChartType }} /> */}
<span className="text-sm text-muted-foreground pr-2">
<div className="flex-1" />
<OverviewViewToggle />
<span className="text-sm text-muted-foreground pr-2 ml-2">
Geo data provided by{' '}
<a
href="https://ipdata.co"

View File

@@ -1,11 +1,11 @@
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
import { useTRPC } from '@/integrations/trpc/react';
import { ModalContent, ModalHeader } from '@/modals/Modal/Container';
import { useInfiniteQuery } from '@tanstack/react-query';
import { Button } from '../ui/button';
import { ScrollArea } from '../ui/scroll-area';
import { OverviewWidgetTablePages } from './overview-widget-table';
import { useQuery } from '@tanstack/react-query';
import { ExternalLinkIcon } from 'lucide-react';
import { SerieIcon } from '../report-chart/common/serie-icon';
import { Tooltiper } from '../ui/tooltip';
import { OverviewListModal } from './overview-list-modal';
import { useOverviewOptions } from './useOverviewOptions';
interface OverviewTopPagesProps {
@@ -18,44 +18,54 @@ export default function OverviewTopPagesModal({
const [filters, setFilter] = useEventQueryFilters();
const { startDate, endDate, range } = useOverviewOptions();
const trpc = useTRPC();
const query = useInfiniteQuery(
trpc.overview.topPages.infiniteQueryOptions(
{
projectId,
filters,
startDate,
endDate,
mode: 'page',
range,
limit: 50,
},
{
getNextPageParam: (_, pages) => pages.length + 1,
},
),
const query = useQuery(
trpc.overview.topPages.queryOptions({
projectId,
filters,
startDate,
endDate,
mode: 'page',
range,
}),
);
const data = query.data?.pages.flat();
return (
<ModalContent>
<ModalHeader title="Top Pages" />
<ScrollArea className="-mx-6 px-2">
<OverviewWidgetTablePages
data={data ?? []}
lastColumnName={'Sessions'}
/>
<div className="row center-center p-4 pb-0">
<Button
variant="outline"
className="w-full"
onClick={() => query.fetchNextPage()}
loading={query.isFetching}
>
Load more
</Button>
</div>
</ScrollArea>
</ModalContent>
<OverviewListModal
title="Top Pages"
searchPlaceholder="Search pages..."
data={query.data ?? []}
keyExtractor={(item) => item.path + item.origin}
searchFilter={(item, query) =>
item.path.toLowerCase().includes(query) ||
item.origin.toLowerCase().includes(query)
}
columnName="Path"
renderItem={(item) => (
<Tooltiper asChild content={item.origin + item.path} side="left">
<div className="flex items-center gap-2 min-w-0">
<SerieIcon name={item.origin} />
<button
type="button"
className="truncate hover:underline"
onClick={() => {
setFilter('path', item.path);
setFilter('origin', item.origin);
}}
>
{item.path || <span className="opacity-40">Not set</span>}
</button>
<a
href={item.origin + item.path}
target="_blank"
rel="noreferrer"
onClick={(e) => e.stopPropagation()}
className="flex-shrink-0"
>
<ExternalLinkIcon className="size-3 opacity-0 group-hover/row:opacity-100 transition-opacity" />
</a>
</div>
</Tooltiper>
)}
/>
);
}

View File

@@ -1,7 +1,7 @@
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
import { cn } from '@/utils/cn';
import { Globe2Icon } from 'lucide-react';
import { parseAsBoolean, useQueryState } from 'nuqs';
import { useMemo, useState } from 'react';
import { useTRPC } from '@/integrations/trpc/react';
import { pushModal } from '@/modals';
@@ -9,8 +9,9 @@ import { useQuery } from '@tanstack/react-query';
import { Button } from '../ui/button';
import { Widget, WidgetBody } from '../widget';
import OverviewDetailsButton from './overview-details-button';
import { WidgetButtons, WidgetFooter, WidgetHead } from './overview-widget';
import { WidgetFooter, WidgetHeadSearchable } from './overview-widget';
import {
OverviewWidgetTableEntries,
OverviewWidgetTableLoading,
OverviewWidgetTablePages,
} from './overview-widget-table';
@@ -25,15 +26,11 @@ export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) {
const { interval, range, startDate, endDate } = useOverviewOptions();
const [filters] = useEventQueryFilters();
const [domain, setDomain] = useQueryState('d', parseAsBoolean);
const [searchQuery, setSearchQuery] = useState('');
const [widget, setWidget, widgets] = useOverviewWidgetV2('pages', {
page: {
title: 'Top pages',
btn: 'Top pages',
meta: {
columns: {
sessions: 'Sessions',
},
},
btn: 'Pages',
},
entry: {
title: 'Entry Pages',
@@ -53,10 +50,6 @@ export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) {
},
},
},
// bot: {
// title: 'Bots',
// btn: 'Bots',
// },
});
const trpc = useTRPC();
@@ -71,37 +64,53 @@ export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) {
}),
);
const data = query.data;
const filteredData = useMemo(() => {
const data = query.data?.slice(0, 15) ?? [];
if (!searchQuery.trim()) {
return data;
}
const queryLower = searchQuery.toLowerCase();
return data.filter(
(item) =>
item.path.toLowerCase().includes(queryLower) ||
item.origin.toLowerCase().includes(queryLower),
);
}, [query.data, searchQuery]);
const tabs = widgets.map((w) => ({
key: w.key,
label: w.btn,
}));
return (
<>
<Widget className="col-span-6 md:col-span-3">
<WidgetHead>
<div className="title">{widget.title}</div>
<WidgetButtons>
{widgets.map((w) => (
<button
type="button"
key={w.key}
onClick={() => setWidget(w.key)}
className={cn(w.key === widget.key && 'active')}
>
{w.btn}
</button>
))}
</WidgetButtons>
</WidgetHead>
<WidgetHeadSearchable
tabs={tabs}
activeTab={widget.key}
onTabChange={setWidget}
searchValue={searchQuery}
onSearchChange={setSearchQuery}
searchPlaceholder={`Search ${widget.btn.toLowerCase()}`}
className="border-b-0 pb-2"
/>
<WidgetBody className="p-0">
{query.isLoading ? (
<OverviewWidgetTableLoading />
) : (
<>
{/*<OverviewWidgetTableBots data={data ?? []} />*/}
<OverviewWidgetTablePages
data={data ?? []}
lastColumnName={widget.meta.columns.sessions}
showDomain={!!domain}
/>
{widget.meta?.columns.sessions ? (
<OverviewWidgetTableEntries
data={filteredData}
lastColumnName={widget.meta.columns.sessions}
showDomain={!!domain}
/>
) : (
<OverviewWidgetTablePages
data={filteredData}
showDomain={!!domain}
/>
)}
</>
)}
</WidgetBody>
@@ -109,7 +118,6 @@ export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) {
<OverviewDetailsButton
onClick={() => pushModal('OverviewTopPagesModal', { projectId })}
/>
{/* <OverviewChartToggle {...{ chartType, setChartType }} /> */}
<div className="flex-1" />
<Button
variant={'ghost'}

View File

@@ -1,5 +1,5 @@
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
import { cn } from '@/utils/cn';
import { useMemo, useState } from 'react';
import { useTRPC } from '@/integrations/trpc/react';
import { pushModal } from '@/modals';
@@ -9,7 +9,12 @@ import { SerieIcon } from '../report-chart/common/serie-icon';
import { Widget, WidgetBody } from '../widget';
import { OVERVIEW_COLUMNS_NAME } from './overview-constants';
import OverviewDetailsButton from './overview-details-button';
import { WidgetButtons, WidgetFooter, WidgetHead } from './overview-widget';
import {
OverviewLineChart,
OverviewLineChartLoading,
} from './overview-line-chart';
import { OverviewViewToggle, useOverviewView } from './overview-view-toggle';
import { WidgetFooter, WidgetHeadSearchable } from './overview-widget';
import {
OverviewWidgetTableGeneric,
OverviewWidgetTableLoading,
@@ -23,16 +28,18 @@ interface OverviewTopSourcesProps {
export default function OverviewTopSources({
projectId,
}: OverviewTopSourcesProps) {
const { range, startDate, endDate } = useOverviewOptions();
const { interval, range, startDate, endDate } = useOverviewOptions();
const [filters, setFilter] = useEventQueryFilters();
const [searchQuery, setSearchQuery] = useState('');
const [view] = useOverviewView();
const [widget, setWidget, widgets] = useOverviewWidgetV2('sources', {
referrer_name: {
title: 'Top sources',
btn: 'All',
btn: 'Refs',
},
referrer: {
title: 'Top urls',
btn: 'URLs',
btn: 'Urls',
},
referrer_type: {
title: 'Top types',
@@ -72,31 +79,67 @@ export default function OverviewTopSources({
}),
);
const seriesQuery = useQuery(
trpc.overview.topGenericSeries.queryOptions(
{
projectId,
range,
filters,
column: widget.key,
startDate,
endDate,
interval,
},
{
enabled: view === 'chart',
},
),
);
const filteredData = useMemo(() => {
const data = (query.data ?? []).slice(0, 15);
if (!searchQuery.trim()) {
return data;
}
const queryLower = searchQuery.toLowerCase();
return data.filter((item) => item.name?.toLowerCase().includes(queryLower));
}, [query.data, searchQuery]);
const tabs = widgets.map((w) => ({
key: w.key,
label: w.btn,
}));
return (
<>
<Widget className="col-span-6 md:col-span-3">
<WidgetHead>
<div className="title">{widget.title}</div>
<WidgetButtons>
{widgets.map((w) => (
<button
type="button"
key={w.key}
onClick={() => setWidget(w.key)}
className={cn(w.key === widget.key && 'active')}
>
{w.btn}
</button>
))}
</WidgetButtons>
</WidgetHead>
<WidgetHeadSearchable
tabs={tabs}
activeTab={widget.key}
onTabChange={setWidget}
searchValue={searchQuery}
onSearchChange={setSearchQuery}
searchPlaceholder={`Search ${widget.btn.toLowerCase()}`}
className="border-b-0 pb-2"
/>
<WidgetBody className="p-0">
{query.isLoading ? (
{view === 'chart' ? (
seriesQuery.isLoading ? (
<OverviewLineChartLoading />
) : seriesQuery.data ? (
<OverviewLineChart
data={seriesQuery.data}
interval={interval}
searchQuery={searchQuery}
/>
) : (
<OverviewLineChartLoading />
)
) : query.isLoading ? (
<OverviewWidgetTableLoading />
) : (
<OverviewWidgetTableGeneric
data={query.data ?? []}
data={filteredData}
column={{
name: OVERVIEW_COLUMNS_NAME[widget.key],
render(item) {
@@ -137,7 +180,8 @@ export default function OverviewTopSources({
})
}
/>
{/* <OverviewChartToggle {...{ chartType, setChartType }} /> */}
<div className="flex-1" />
<OverviewViewToggle />
</WidgetFooter>
</Widget>
</>

View File

@@ -0,0 +1,54 @@
import { LineChartIcon, TableIcon } from 'lucide-react';
import { parseAsStringEnum, useQueryState } from 'nuqs';
import { Button } from '../ui/button';
type ViewType = 'table' | 'chart';
interface OverviewViewToggleProps {
defaultView?: ViewType;
className?: string;
}
export function OverviewViewToggle({
defaultView = 'table',
className,
}: OverviewViewToggleProps) {
const [view, setView] = useQueryState<ViewType>(
'view',
parseAsStringEnum(['table', 'chart'])
.withDefault(defaultView)
.withOptions({ history: 'push' }),
);
return (
<div className={className}>
<Button
size="icon"
variant="ghost"
onClick={() => {
setView(view === 'table' ? 'chart' : 'table');
}}
title={view === 'table' ? 'Switch to chart view' : 'Switch to table view'}
>
{view === 'table' ? (
<LineChartIcon size={16} />
) : (
<TableIcon size={16} />
)}
</Button>
</div>
);
}
export function useOverviewView() {
const [view, setView] = useQueryState<ViewType>(
'view',
parseAsStringEnum(['table', 'chart'])
.withDefault('table')
.withOptions({ history: 'push' }),
);
return [view, setView] as const;
}

View File

@@ -61,7 +61,7 @@ export const OverviewWidgetTable = <T,>({
<WidgetTable
data={data ?? []}
keyExtractor={keyExtractor}
className={'text-sm min-h-[358px] @container [&_.head]:pt-3'}
className={'text-sm min-h-[358px] @container'}
columnClassName="[&_.cell:first-child]:pl-4 [&_.cell:last-child]:pr-4"
eachRow={(item) => {
return (
@@ -109,15 +109,6 @@ export function OverviewWidgetTableLoading({
render: () => <Skeleton className="h-4 w-1/3" />,
width: 'w-full',
},
{
name: 'BR',
render: () => <Skeleton className="h-4 w-[30px]" />,
width: '60px',
},
// {
// name: 'Duration',
// render: () => <Skeleton className="h-4 w-[30px]" />,
// },
{
name: 'Sessions',
render: () => <Skeleton className="h-4 w-[30px]" />,
@@ -142,27 +133,24 @@ function getPath(path: string, showDomain = false) {
export function OverviewWidgetTablePages({
data,
lastColumnName,
className,
showDomain = false,
}: {
className?: string;
lastColumnName: string;
data: {
origin: string;
path: string;
avg_duration: number;
bounce_rate: number;
sessions: number;
revenue: number;
pageviews: number;
revenue?: number;
}[];
showDomain?: boolean;
}) {
const [_filters, setFilter] = useEventQueryFilters();
const number = useNumber();
const maxSessions = Math.max(...data.map((item) => item.sessions));
const totalRevenue = data.reduce((sum, item) => sum + item.revenue, 0);
const hasRevenue = data.some((item) => item.revenue > 0);
const totalRevenue = data.reduce((sum, item) => sum + (item.revenue ?? 0), 0);
const hasRevenue = data.some((item) => (item.revenue ?? 0) > 0);
return (
<OverviewWidgetTable
className={className}
@@ -214,20 +202,135 @@ export function OverviewWidgetTablePages({
);
},
},
...(hasRevenue
? [
{
name: 'Revenue',
width: '100px',
responsive: { priority: 3 }, // Always show if possible
render(item: (typeof data)[number]) {
const revenue = item.revenue ?? 0;
const revenuePercentage =
totalRevenue > 0 ? revenue / totalRevenue : 0;
return (
<div className="row gap-2 items-center justify-end">
<span
className="font-semibold"
style={{ color: '#3ba974' }}
>
{revenue > 0 ? number.currency(revenue / 100) : '-'}
</span>
<RevenuePieChart percentage={revenuePercentage} />
</div>
);
},
} as const,
]
: []),
{
name: 'BR',
width: '60px',
responsive: { priority: 6 }, // Hidden when space is tight
name: 'Views',
width: '84px',
responsive: { priority: 2 }, // Always show if possible
render(item) {
return number.shortWithUnit(item.bounce_rate, '%');
return (
<div className="row gap-2 justify-end">
<span className="font-semibold">
{number.short(item.pageviews)}
</span>
</div>
);
},
},
{
name: 'Duration',
width: '75px',
responsive: { priority: 7 }, // Hidden when space is tight
name: 'Sess.',
width: '84px',
responsive: { priority: 2 }, // Always show if possible
render(item) {
return number.shortWithUnit(item.avg_duration, 'min');
return (
<div className="row gap-2 justify-end">
<span className="font-semibold">
{number.short(item.sessions)}
</span>
</div>
);
},
},
]}
/>
);
}
export function OverviewWidgetTableEntries({
data,
lastColumnName,
className,
showDomain = false,
}: {
className?: string;
lastColumnName: string;
data: {
origin: string;
path: string;
sessions: number;
pageviews: number;
revenue?: number;
}[];
showDomain?: boolean;
}) {
const [_filters, setFilter] = useEventQueryFilters();
const number = useNumber();
const maxSessions = Math.max(...data.map((item) => item.sessions));
const totalRevenue = data.reduce((sum, item) => sum + (item.revenue ?? 0), 0);
const hasRevenue = data.some((item) => (item.revenue ?? 0) > 0);
return (
<OverviewWidgetTable
className={className}
data={data ?? []}
keyExtractor={(item) => item.path + item.origin}
getColumnPercentage={(item) => item.sessions / maxSessions}
columns={[
{
name: 'Path',
width: 'w-full',
responsive: { priority: 1 }, // Always visible
render(item) {
return (
<Tooltiper asChild content={item.origin + item.path} side="left">
<div className="row items-center gap-2 min-w-0 relative">
<SerieIcon name={item.origin} />
<button
type="button"
className="truncate"
onClick={() => {
setFilter('path', item.path);
setFilter('origin', item.origin);
}}
>
{item.path ? (
<>
{showDomain ? (
<>
<span className="opacity-40">{item.origin}</span>
<span>{item.path}</span>
</>
) : (
item.path
)}
</>
) : (
<span className="opacity-40">Not set</span>
)}
</button>
<a
href={item.origin + item.path}
target="_blank"
rel="noreferrer"
>
<ExternalLinkIcon className="size-3 group-hover/row:opacity-100 opacity-0 transition-opacity" />
</a>
</div>
</Tooltiper>
);
},
},
...(hasRevenue
@@ -237,17 +340,16 @@ export function OverviewWidgetTablePages({
width: '100px',
responsive: { priority: 3 }, // Always show if possible
render(item: (typeof data)[number]) {
const revenue = item.revenue ?? 0;
const revenuePercentage =
totalRevenue > 0 ? item.revenue / totalRevenue : 0;
totalRevenue > 0 ? revenue / totalRevenue : 0;
return (
<div className="row gap-2 items-center justify-end">
<span
className="font-semibold"
style={{ color: '#3ba974' }}
>
{item.revenue > 0
? number.currency(item.revenue / 100)
: '-'}
{revenue > 0 ? number.currency(revenue / 100) : '-'}
</span>
<RevenuePieChart percentage={revenuePercentage} />
</div>
@@ -373,6 +475,7 @@ export function OverviewWidgetTableGeneric({
const maxSessions = Math.max(...data.map((item) => item.sessions));
const totalRevenue = data.reduce((sum, item) => sum + (item.revenue ?? 0), 0);
const hasRevenue = data.some((item) => (item.revenue ?? 0) > 0);
const hasPageviews = data.some((item) => item.pageviews > 0);
return (
<OverviewWidgetTable
className={className}
@@ -385,27 +488,12 @@ export function OverviewWidgetTableGeneric({
width: 'w-full',
responsive: { priority: 1 }, // Always visible
},
{
name: 'BR',
width: '60px',
responsive: { priority: 6 }, // Hidden when space is tight
render(item) {
return number.shortWithUnit(item.bounce_rate, '%');
},
},
// {
// name: 'Duration',
// render(item) {
// return number.shortWithUnit(item.avg_session_duration, 'min');
// },
// },
...(hasRevenue
? [
{
name: 'Revenue',
width: '100px',
responsive: { priority: 3 }, // Always show if possible
responsive: { priority: 3 },
render(item: RouterOutputs['overview']['topGeneric'][number]) {
const revenue = item.revenue ?? 0;
const revenuePercentage =
@@ -427,10 +515,28 @@ export function OverviewWidgetTableGeneric({
} as const,
]
: []),
...(hasPageviews
? [
{
name: 'Views',
width: '84px',
responsive: { priority: 2 },
render(item: RouterOutputs['overview']['topGeneric'][number]) {
return (
<div className="row gap-2 justify-end">
<span className="font-semibold">
{number.short(item.pageviews)}
</span>
</div>
);
},
} as const,
]
: []),
{
name: 'Sessions',
name: 'Sess.',
width: '84px',
responsive: { priority: 2 }, // Always show if possible
responsive: { priority: 2 },
render(item) {
return (
<div className="row gap-2 justify-end">
@@ -445,3 +551,65 @@ export function OverviewWidgetTableGeneric({
/>
);
}
export type EventTableItem = {
id: string;
name: string;
count: number;
};
export function OverviewWidgetTableEvents({
data,
className,
onItemClick,
}: {
className?: string;
data: EventTableItem[];
onItemClick?: (name: string) => void;
}) {
const number = useNumber();
const maxCount = Math.max(...data.map((item) => item.count), 1);
return (
<OverviewWidgetTable
className={className}
data={data ?? []}
keyExtractor={(item) => item.id}
getColumnPercentage={(item) => item.count / maxCount}
columns={[
{
name: 'Event',
width: 'w-full',
responsive: { priority: 1 },
render(item) {
return (
<div className="row items-center gap-2 min-w-0 relative">
<SerieIcon name={item.name} />
<button
type="button"
className="truncate"
onClick={() => onItemClick?.(item.name)}
>
{item.name || 'Not set'}
</button>
</div>
);
},
},
{
name: 'Count',
width: '84px',
responsive: { priority: 2 },
render(item) {
return (
<div className="row gap-2 justify-end">
<span className="font-semibold">
{number.short(item.count)}
</span>
</div>
);
},
},
]}
/>
);
}

View File

@@ -1,8 +1,8 @@
import { useThrottle } from '@/hooks/use-throttle';
import { cn } from '@/utils/cn';
import { ChevronsUpDownIcon, Icon, type LucideIcon } from 'lucide-react';
import { ChevronsUpDownIcon, type LucideIcon, SearchIcon } from 'lucide-react';
import { last } from 'ramda';
import { Children, useEffect, useRef, useState } from 'react';
import { Children, useCallback, useEffect, useRef, useState } from 'react';
import {
DropdownMenu,
@@ -11,6 +11,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '../ui/dropdown-menu';
import { Input } from '../ui/input';
import type { WidgetHeadProps, WidgetTitleProps } from '../widget';
import { WidgetHead as WidgetHeadBase } from '../widget';
@@ -169,6 +170,128 @@ export function WidgetButtons({
);
}
interface WidgetTab<T extends string = string> {
key: T;
label: string;
}
interface WidgetHeadSearchableProps<T extends string = string> {
tabs: WidgetTab<T>[];
activeTab: T;
className?: string;
onTabChange: (key: T) => void;
searchValue?: string;
onSearchChange?: (value: string) => void;
searchPlaceholder?: string;
}
export function WidgetHeadSearchable<T extends string>({
tabs,
className,
activeTab,
onTabChange,
searchValue,
onSearchChange,
searchPlaceholder = 'Search',
}: WidgetHeadSearchableProps<T>) {
const scrollRef = useRef<HTMLDivElement>(null);
const [showLeftGradient, setShowLeftGradient] = useState(false);
const [showRightGradient, setShowRightGradient] = useState(false);
const updateGradients = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
const { scrollLeft, scrollWidth, clientWidth } = el;
const hasOverflow = scrollWidth > clientWidth;
setShowLeftGradient(hasOverflow && scrollLeft > 0);
setShowRightGradient(
hasOverflow && scrollLeft < scrollWidth - clientWidth - 1,
);
}, []);
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
updateGradients();
el.addEventListener('scroll', updateGradients);
window.addEventListener('resize', updateGradients);
return () => {
el.removeEventListener('scroll', updateGradients);
window.removeEventListener('resize', updateGradients);
};
}, [updateGradients]);
// Update gradients when tabs change
useEffect(() => {
// Use RAF to ensure DOM has updated
requestAnimationFrame(updateGradients);
}, [tabs, updateGradients]);
return (
<div className={cn('border-b border-border', className)}>
{/* Scrollable tabs container */}
<div className="relative">
{/* Left gradient */}
<div
className={cn(
'pointer-events-none absolute left-0 top-0 z-10 h-full w-8 bg-gradient-to-r from-card to-transparent transition-opacity duration-200',
showLeftGradient ? 'opacity-100' : 'opacity-0',
)}
/>
{/* Scrollable tabs */}
<div
ref={scrollRef}
className="flex gap-1 overflow-x-auto px-2 py-3 hide-scrollbar"
>
{tabs.map((tab) => (
<button
key={tab.key}
type="button"
onClick={() => onTabChange(tab.key)}
className={cn(
'shrink-0 rounded-md py-1.5 text-sm font-medium transition-colors px-2',
activeTab === tab.key
? 'text-foreground'
: 'text-muted-foreground hover:bg-def-100 hover:text-foreground',
)}
>
{tab.label}
</button>
))}
</div>
{/* Right gradient */}
<div
className={cn(
'pointer-events-none absolute right-0 top-0 z-10 bottom-px w-8 bg-gradient-to-l from-card to-transparent transition-opacity duration-200',
showRightGradient ? 'opacity-100' : 'opacity-0',
)}
/>
</div>
{/* Search input */}
{onSearchChange && (
<div className="relative">
<SearchIcon className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
type="search"
placeholder={searchPlaceholder}
value={searchValue ?? ''}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-9 bg-transparent border-0 text-sm rounded-none focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground focus-visible:ring-offset-0 border-y"
/>
</div>
)}
</div>
);
}
export function WidgetFooter({
className,
children,

View File

@@ -33,7 +33,10 @@ export function useOverviewWidget<T extends string>(
export function useOverviewWidgetV2<T extends string>(
key: string,
widgets: Record<T, { title: string; btn: string; meta?: any }>,
widgets: Record<
T,
{ title: string; btn: string; meta?: any; hide?: boolean }
>,
) {
const keys = Object.keys(widgets) as T[];
const [widget, setWidget] = useQueryState<T>(