This commit is contained in:
Carl-Gerhard Lindesvärd
2025-11-22 00:05:13 +01:00
parent 06fb6c4f3c
commit 57697a5a39
12 changed files with 1310 additions and 511 deletions

View File

@@ -7,6 +7,7 @@ import { cn } from '@/utils/cn';
import { getChartColor } from '@/utils/theme'; import { getChartColor } from '@/utils/theme';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { isSameDay, isSameHour, isSameMonth, isSameWeek } from 'date-fns'; import { isSameDay, isSameHour, isSameMonth, isSameWeek } from 'date-fns';
import { BookmarkIcon, UsersIcon } from 'lucide-react';
import { last } from 'ramda'; import { last } from 'ramda';
import { useCallback } from 'react'; import { useCallback } from 'react';
import { import {
@@ -25,6 +26,10 @@ import {
import { useDashedStroke } from '@/hooks/use-dashed-stroke'; import { useDashedStroke } from '@/hooks/use-dashed-stroke';
import { useXAxisProps, useYAxisProps } from '../common/axis'; import { useXAxisProps, useYAxisProps } from '../common/axis';
import {
ChartClickMenu,
type ChartClickMenuItem,
} from '../common/chart-click-menu';
import { ReportChartTooltip } from '../common/report-chart-tooltip'; import { ReportChartTooltip } from '../common/report-chart-tooltip';
import { ReportTable } from '../common/report-table'; import { ReportTable } from '../common/report-table';
import { SerieIcon } from '../common/serie-icon'; import { SerieIcon } from '../common/serie-icon';
@@ -45,6 +50,8 @@ export function Chart({ data }: Props) {
endDate, endDate,
range, range,
lineType, lineType,
series: reportSeries,
breakdowns,
}, },
isEditMode, isEditMode,
options: { hideXAxis, hideYAxis }, options: { hideXAxis, hideYAxis },
@@ -126,16 +133,66 @@ export function Chart({ data }: Props) {
interval, interval,
}); });
const handleChartClick = useCallback((e: any) => { const getMenuItems = useCallback(
if (e?.activePayload?.[0]) { (e: any, clickedData: any): ChartClickMenuItem[] => {
const clickedData = e.activePayload[0].payload; const items: ChartClickMenuItem[] = [];
if (clickedData.date) {
pushModal('AddReference', { if (!clickedData?.date) {
datetime: new Date(clickedData.date).toISOString(), return items;
}
// View Users - only show if we have projectId
if (projectId) {
items.push({
label: 'View Users',
icon: <UsersIcon size={16} />,
onClick: () => {
pushModal('ViewChartUsers', {
type: 'chart',
chartData: data,
report: {
projectId,
series: reportSeries,
breakdowns: breakdowns || [],
interval,
startDate,
endDate,
range,
previous,
chartType: 'area',
metric: 'sum',
},
date: clickedData.date,
});
},
}); });
} }
}
}, []); // Add Reference - always show
items.push({
label: 'Add Reference',
icon: <BookmarkIcon size={16} />,
onClick: () => {
pushModal('AddReference', {
datetime: new Date(clickedData.date).toISOString(),
});
},
});
return items;
},
[
projectId,
data,
reportSeries,
breakdowns,
interval,
startDate,
endDate,
range,
previous,
],
);
const { getStrokeDasharray, calcStrokeDasharray, handleAnimationEnd } = const { getStrokeDasharray, calcStrokeDasharray, handleAnimationEnd } =
useDashedStroke({ useDashedStroke({
@@ -144,9 +201,10 @@ export function Chart({ data }: Props) {
return ( return (
<ReportChartTooltip.TooltipProvider references={references.data}> <ReportChartTooltip.TooltipProvider references={references.data}>
<div className={cn('h-full w-full', isEditMode && 'card p-4')}> <ChartClickMenu getMenuItems={getMenuItems}>
<ResponsiveContainer> <div className={cn('h-full w-full', isEditMode && 'card p-4')}>
<ComposedChart data={rechartData} onClick={handleChartClick}> <ResponsiveContainer>
<ComposedChart data={rechartData}>
<Customized component={calcStrokeDasharray} /> <Customized component={calcStrokeDasharray} />
<Line <Line
dataKey="calcStrokeDasharray" dataKey="calcStrokeDasharray"
@@ -244,6 +302,7 @@ export function Chart({ data }: Props) {
setVisibleSeries={setVisibleSeries} setVisibleSeries={setVisibleSeries}
/> />
)} )}
</ChartClickMenu>
</ReportChartTooltip.TooltipProvider> </ReportChartTooltip.TooltipProvider>
); );
} }

View File

@@ -0,0 +1,263 @@
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import React, {
forwardRef,
useCallback,
useImperativeHandle,
useRef,
useState,
} from 'react';
export interface ChartClickMenuItem {
label: string;
icon?: React.ReactNode;
onClick: () => void;
disabled?: boolean;
}
interface ChartClickMenuProps {
children: React.ReactNode;
/**
* Function that receives the click event and clicked data, returns menu items
* This allows conditional menu items based on what was clicked
*/
getMenuItems: (e: any, clickedData: any) => ChartClickMenuItem[];
/**
* Optional callback when menu closes
*/
onClose?: () => void;
}
export interface ChartClickMenuHandle {
setPosition: (position: { x: number; y: number } | null) => void;
getContainerElement: () => HTMLDivElement | null;
}
/**
* Reusable component for handling chart clicks and showing a dropdown menu
* Wraps the chart and handles click position tracking and dropdown positioning
*/
export const ChartClickMenu = forwardRef<
ChartClickMenuHandle,
ChartClickMenuProps
>(({ children, getMenuItems, onClose }, ref) => {
const containerRef = useRef<HTMLDivElement>(null);
const [clickPosition, setClickPosition] = useState<{
x: number;
y: number;
} | null>(null);
const [clickedData, setClickedData] = useState<any>(null);
const [clickEvent, setClickEvent] = useState<any>(null);
const handleChartClick = useCallback((e: any) => {
if (e?.activePayload?.[0] && containerRef.current) {
const payload = e.activePayload[0].payload;
// Calculate click position relative to chart container
const containerRect = containerRef.current.getBoundingClientRect();
// Try to get viewport coordinates from the event
// Recharts passes nativeEvent with clientX/clientY (viewport coordinates)
let clientX = 0;
let clientY = 0;
if (
e.nativeEvent?.clientX !== undefined &&
e.nativeEvent?.clientY !== undefined
) {
// Best case: use nativeEvent client coordinates (viewport coordinates)
clientX = e.nativeEvent.clientX;
clientY = e.nativeEvent.clientY;
} else if (e.clientX !== undefined && e.clientY !== undefined) {
// Fallback: use event's clientX/Y directly
clientX = e.clientX;
clientY = e.clientY;
} else if (e.activeCoordinate) {
// Last resort: activeCoordinate is SVG-relative, need to find SVG element
// and convert to viewport coordinates
const svgElement = containerRef.current.querySelector('svg');
if (svgElement) {
const svgRect = svgElement.getBoundingClientRect();
clientX = svgRect.left + (e.activeCoordinate.x ?? 0);
clientY = svgRect.top + (e.activeCoordinate.y ?? 0);
} else {
// If no SVG found, use container position + activeCoordinate
clientX = containerRect.left + (e.activeCoordinate.x ?? 0);
clientY = containerRect.top + (e.activeCoordinate.y ?? 0);
}
}
setClickedData(payload);
setClickEvent(e); // Store the full event
setClickPosition({
x: clientX - containerRect.left,
y: clientY - containerRect.top,
});
}
}, []);
const menuItems =
clickedData && clickEvent ? getMenuItems(clickEvent, clickedData) : [];
const handleItemClick = useCallback(
(item: ChartClickMenuItem) => {
item.onClick();
setClickPosition(null);
setClickedData(null);
setClickEvent(null);
if (onClose) {
onClose();
}
},
[onClose],
);
const handleOpenChange = useCallback(
(open: boolean) => {
if (!open) {
setClickPosition(null);
setClickedData(null);
setClickEvent(null);
if (onClose) {
onClose();
}
}
},
[onClose],
);
// Expose methods via ref (for advanced use cases)
useImperativeHandle(
ref,
() => ({
setPosition: (position: { x: number; y: number } | null) => {
setClickPosition(position);
},
getContainerElement: () => containerRef.current,
}),
[],
);
// Clone children and add onClick handler to chart components
const chartWithClickHandler = React.useMemo(() => {
const addClickHandler = (node: React.ReactNode): React.ReactNode => {
// Handle null, undefined, strings, numbers
if (!React.isValidElement(node)) {
return node;
}
// Check if this is a chart component
const componentName =
(node.type as any)?.displayName || (node.type as any)?.name;
const isChartComponent =
componentName === 'ComposedChart' ||
componentName === 'LineChart' ||
componentName === 'BarChart' ||
componentName === 'AreaChart' ||
componentName === 'PieChart' ||
componentName === 'ResponsiveContainer';
// Process children recursively - handle arrays, fragments, and single elements
const processChildren = (children: React.ReactNode): React.ReactNode => {
if (children == null) {
return children;
}
// Handle arrays
if (Array.isArray(children)) {
return children.map(addClickHandler);
}
// Handle React fragments
if (
React.isValidElement(children) &&
children.type === React.Fragment
) {
const fragmentElement = children as React.ReactElement<{
children?: React.ReactNode;
}>;
return React.cloneElement(fragmentElement, {
children: processChildren(fragmentElement.props.children),
});
}
// Recursively process single child
return addClickHandler(children);
};
const element = node as React.ReactElement<{
children?: React.ReactNode;
onClick?: (e: any) => void;
}>;
if (isChartComponent) {
// For ResponsiveContainer, we need to add onClick to its child (ComposedChart, etc.)
if (componentName === 'ResponsiveContainer') {
return React.cloneElement(element, {
children: processChildren(element.props.children),
});
}
// For chart components, add onClick directly
return React.cloneElement(element, {
onClick: handleChartClick,
children: processChildren(element.props.children),
});
}
// Recursively process children for non-chart components
if (element.props.children != null) {
return React.cloneElement(element, {
children: processChildren(element.props.children),
});
}
return node;
};
// Handle multiple children (array) or single child
if (Array.isArray(children)) {
return children.map(addClickHandler);
}
return addClickHandler(children);
}, [children, handleChartClick]);
return (
<div ref={containerRef} className="relative h-full w-full">
<DropdownMenu
open={clickPosition !== null}
onOpenChange={handleOpenChange}
>
<DropdownMenuTrigger asChild>
<div
style={{
position: 'absolute',
left: clickPosition?.x ?? -9999,
top: clickPosition?.y ?? -9999,
pointerEvents: 'none',
}}
/>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" side="bottom" sideOffset={5}>
{menuItems.map((item) => (
<DropdownMenuItem
key={item.label}
onClick={() => handleItemClick(item)}
disabled={item.disabled}
>
{item.icon && <span className="mr-2">{item.icon}</span>}
{item.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{chartWithClickHandler}
</div>
);
});
ChartClickMenu.displayName = 'ChartClickMenu';

View File

@@ -1,7 +1,9 @@
import { ColorSquare } from '@/components/color-square'; import { ColorSquare } from '@/components/color-square';
import { Button } from '@/components/ui/button';
import { pushModal } from '@/modals';
import type { RouterOutputs } from '@/trpc/client'; import type { RouterOutputs } from '@/trpc/client';
import { cn } from '@/utils/cn'; import { cn } from '@/utils/cn';
import { ChevronRightIcon, InfoIcon } from 'lucide-react'; import { ChevronRightIcon, InfoIcon, UsersIcon } from 'lucide-react';
import { alphabetIds } from '@openpanel/constants'; import { alphabetIds } from '@openpanel/constants';
@@ -23,6 +25,7 @@ import {
} from 'recharts'; } from 'recharts';
import { useXAxisProps, useYAxisProps } from '../common/axis'; import { useXAxisProps, useYAxisProps } from '../common/axis';
import { PreviousDiffIndicatorPure } from '../common/previous-diff-indicator'; import { PreviousDiffIndicatorPure } from '../common/previous-diff-indicator';
import { useReportChartContext } from '../context';
type Props = { type Props = {
data: { data: {
@@ -113,11 +116,50 @@ function ChartName({
export function Tables({ export function Tables({
data: { data: {
current: { steps, mostDropoffsStep, lastStep, breakdowns }, current: { steps, mostDropoffsStep, lastStep, breakdowns },
previous, previous: previousData,
}, },
}: Props) { }: Props) {
const number = useNumber(); const number = useNumber();
const hasHeader = breakdowns.length > 0; const hasHeader = breakdowns.length > 0;
const {
report: {
projectId,
startDate,
endDate,
range,
interval,
series: reportSeries,
breakdowns: reportBreakdowns,
previous,
funnelWindow,
funnelGroup,
},
} = useReportChartContext();
const handleInspectStep = (step: (typeof steps)[0], stepIndex: number) => {
if (!projectId || !step.event.id) return;
// For funnels, we need to pass the step index so the modal can query
// users who completed at least that step in the funnel sequence
pushModal('ViewChartUsers', {
type: 'funnel',
report: {
projectId,
series: reportSeries,
breakdowns: reportBreakdowns || [],
interval: interval || 'day',
startDate,
endDate,
range,
previous,
chartType: 'funnel',
metric: 'sum',
funnelWindow,
funnelGroup,
},
stepIndex, // Pass the step index for funnel queries
});
};
return ( return (
<div className={cn('col @container divide-y divide-border card')}> <div className={cn('col @container divide-y divide-border card')}>
{hasHeader && <ChartName breakdowns={breakdowns} className="p-4 py-3" />} {hasHeader && <ChartName breakdowns={breakdowns} className="p-4 py-3" />}
@@ -128,11 +170,11 @@ export function Tables({
label="Conversion" label="Conversion"
value={number.formatWithUnit(lastStep?.percent / 100, '%')} value={number.formatWithUnit(lastStep?.percent / 100, '%')}
enhancer={ enhancer={
previous && ( previousData && (
<PreviousDiffIndicatorPure <PreviousDiffIndicatorPure
{...getPreviousMetric( {...getPreviousMetric(
lastStep?.percent, lastStep?.percent,
previous.lastStep?.percent, previousData.lastStep?.percent,
)} )}
/> />
) )
@@ -143,11 +185,11 @@ export function Tables({
label="Completed" label="Completed"
value={number.format(lastStep?.count)} value={number.format(lastStep?.count)}
enhancer={ enhancer={
previous && ( previousData && (
<PreviousDiffIndicatorPure <PreviousDiffIndicatorPure
{...getPreviousMetric( {...getPreviousMetric(
lastStep?.count, lastStep?.count,
previous.lastStep?.count, previousData.lastStep?.count,
)} )}
/> />
) )
@@ -238,6 +280,28 @@ export function Tables({
className: 'text-right font-mono font-semibold', className: 'text-right font-mono font-semibold',
width: '90px', width: '90px',
}, },
{
name: '',
render: (item) => (
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0"
onClick={(e) => {
e.stopPropagation();
const stepIndex = steps.findIndex(
(s) => s.event.id === item.event.id,
);
handleInspectStep(item, stepIndex);
}}
title="View users who completed this step"
>
<UsersIcon size={16} />
</Button>
),
className: 'text-right',
width: '48px',
},
]} ]}
/> />
</div> </div>

View File

@@ -7,6 +7,7 @@ import type { IChartData } from '@/trpc/client';
import { cn } from '@/utils/cn'; import { cn } from '@/utils/cn';
import { getChartColor } from '@/utils/theme'; import { getChartColor } from '@/utils/theme';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { BookmarkIcon, UsersIcon } from 'lucide-react';
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import { import {
Bar, Bar,
@@ -20,6 +21,10 @@ import {
} from 'recharts'; } from 'recharts';
import { useXAxisProps, useYAxisProps } from '../common/axis'; import { useXAxisProps, useYAxisProps } from '../common/axis';
import {
ChartClickMenu,
type ChartClickMenuItem,
} from '../common/chart-click-menu';
import { ReportChartTooltip } from '../common/report-chart-tooltip'; import { ReportChartTooltip } from '../common/report-chart-tooltip';
import { ReportTable } from '../common/report-table'; import { ReportTable } from '../common/report-table';
import { useReportChartContext } from '../context'; import { useReportChartContext } from '../context';
@@ -47,7 +52,16 @@ function BarHover({ x, y, width, height, top, left, right, bottom }: any) {
export function Chart({ data }: Props) { export function Chart({ data }: Props) {
const { const {
isEditMode, isEditMode,
report: { previous, interval, projectId, startDate, endDate, range }, report: {
previous,
interval,
projectId,
startDate,
endDate,
range,
series: reportSeries,
breakdowns,
},
options: { hideXAxis, hideYAxis }, options: { hideXAxis, hideYAxis },
} = useReportChartContext(); } = useReportChartContext();
const trpc = useTRPC(); const trpc = useTRPC();
@@ -74,22 +88,73 @@ export function Chart({ data }: Props) {
interval, interval,
}); });
const handleChartClick = useCallback((e: any) => { const getMenuItems = useCallback(
if (e?.activePayload?.[0]) { (e: any, clickedData: any): ChartClickMenuItem[] => {
const clickedData = e.activePayload[0].payload; const items: ChartClickMenuItem[] = [];
if (clickedData.date) {
pushModal('AddReference', { if (!clickedData?.date) {
datetime: new Date(clickedData.date).toISOString(), return items;
}
// View Users - only show if we have projectId
if (projectId) {
items.push({
label: 'View Users',
icon: <UsersIcon size={16} />,
onClick: () => {
pushModal('ViewChartUsers', {
type: 'chart',
chartData: data,
report: {
projectId,
series: reportSeries,
breakdowns: breakdowns || [],
interval,
startDate,
endDate,
range,
previous,
chartType: 'histogram',
metric: 'sum',
},
date: clickedData.date,
});
},
}); });
} }
}
}, []); // Add Reference - always show
items.push({
label: 'Add Reference',
icon: <BookmarkIcon size={16} />,
onClick: () => {
pushModal('AddReference', {
datetime: new Date(clickedData.date).toISOString(),
});
},
});
return items;
},
[
projectId,
data,
reportSeries,
breakdowns,
interval,
startDate,
endDate,
range,
previous,
],
);
return ( return (
<ReportChartTooltip.TooltipProvider references={references.data}> <ReportChartTooltip.TooltipProvider references={references.data}>
<div className={cn('h-full w-full', isEditMode && 'card p-4')}> <ChartClickMenu getMenuItems={getMenuItems}>
<ResponsiveContainer> <div className={cn('h-full w-full', isEditMode && 'card p-4')}>
<BarChart data={rechartData} onClick={handleChartClick}> <ResponsiveContainer>
<BarChart data={rechartData}>
<CartesianGrid <CartesianGrid
strokeDasharray="3 3" strokeDasharray="3 3"
vertical={false} vertical={false}
@@ -152,6 +217,7 @@ export function Chart({ data }: Props) {
setVisibleSeries={setVisibleSeries} setVisibleSeries={setVisibleSeries}
/> />
)} )}
</ChartClickMenu>
</ReportChartTooltip.TooltipProvider> </ReportChartTooltip.TooltipProvider>
); );
} }

View File

@@ -1,9 +1,3 @@
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useRechartDataModel } from '@/hooks/use-rechart-data-model'; import { useRechartDataModel } from '@/hooks/use-rechart-data-model';
import { useVisibleSeries } from '@/hooks/use-visible-series'; import { useVisibleSeries } from '@/hooks/use-visible-series';
import { useTRPC } from '@/integrations/trpc/react'; import { useTRPC } from '@/integrations/trpc/react';
@@ -11,12 +5,11 @@ import { pushModal } from '@/modals';
import type { IChartData } from '@/trpc/client'; import type { IChartData } from '@/trpc/client';
import { cn } from '@/utils/cn'; import { cn } from '@/utils/cn';
import { getChartColor } from '@/utils/theme'; import { getChartColor } from '@/utils/theme';
import type { IChartEvent } from '@openpanel/validation';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { isSameDay, isSameHour, isSameMonth, isSameWeek } from 'date-fns'; import { isSameDay, isSameHour, isSameMonth, isSameWeek } from 'date-fns';
import { BookmarkIcon, UsersIcon } from 'lucide-react'; import { BookmarkIcon, UsersIcon } from 'lucide-react';
import { last } from 'ramda'; import { last } from 'ramda';
import { useCallback, useState } from 'react'; import { useCallback } from 'react';
import { import {
CartesianGrid, CartesianGrid,
ComposedChart, ComposedChart,
@@ -32,6 +25,10 @@ import {
import { useDashedStroke } from '@/hooks/use-dashed-stroke'; import { useDashedStroke } from '@/hooks/use-dashed-stroke';
import { useXAxisProps, useYAxisProps } from '../common/axis'; import { useXAxisProps, useYAxisProps } from '../common/axis';
import {
ChartClickMenu,
type ChartClickMenuItem,
} from '../common/chart-click-menu';
import { ReportChartTooltip } from '../common/report-chart-tooltip'; import { ReportChartTooltip } from '../common/report-chart-tooltip';
import { ReportTable } from '../common/report-table'; import { ReportTable } from '../common/report-table';
import { SerieIcon } from '../common/serie-icon'; import { SerieIcon } from '../common/serie-icon';
@@ -58,14 +55,6 @@ export function Chart({ data }: Props) {
isEditMode, isEditMode,
options: { hideXAxis, hideYAxis, maxDomain }, options: { hideXAxis, hideYAxis, maxDomain },
} = useReportChartContext(); } = useReportChartContext();
const [clickPosition, setClickPosition] = useState<{
x: number;
y: number;
} | null>(null);
const [clickedData, setClickedData] = useState<{
date: string;
serieId?: string;
} | null>(null);
const dataLength = data.series[0]?.data?.length || 0; const dataLength = data.series[0]?.data?.length || 0;
const trpc = useTRPC(); const trpc = useTRPC();
const references = useQuery( const references = useQuery(
@@ -146,171 +135,146 @@ export function Chart({ data }: Props) {
hide: hideYAxis, hide: hideYAxis,
}); });
const handleChartClick = useCallback((e: any) => { const getMenuItems = useCallback(
if (e?.activePayload?.[0]) { (e: any, clickedData: any): ChartClickMenuItem[] => {
const payload = e.activePayload[0].payload; const items: ChartClickMenuItem[] = [];
const activeCoordinate = e.activeCoordinate;
if (payload.date) {
// Find the first valid serie ID from activePayload (skip calcStrokeDasharray)
const validPayload = e.activePayload.find(
(p: any) =>
p.dataKey &&
p.dataKey !== 'calcStrokeDasharray' &&
typeof p.dataKey === 'string' &&
p.dataKey.includes(':count'),
);
const serieId = validPayload?.dataKey?.toString().replace(':count', '');
setClickedData({ if (!clickedData?.date) {
date: payload.date, return items;
serieId, }
});
setClickPosition({ // Extract serie ID from the click event if needed
x: activeCoordinate?.x ?? 0, // activePayload is an array of payload objects
y: activeCoordinate?.y ?? 0, const validPayload = e.activePayload?.find(
(p: any) =>
p.dataKey &&
p.dataKey !== 'calcStrokeDasharray' &&
typeof p.dataKey === 'string' &&
p.dataKey.includes(':count'),
);
const serieId = validPayload?.dataKey?.toString().replace(':count', '');
// View Users - only show if we have projectId
if (projectId) {
items.push({
label: 'View Users',
icon: <UsersIcon size={16} />,
onClick: () => {
pushModal('ViewChartUsers', {
type: 'chart',
chartData: data,
report: {
projectId,
series: reportSeries,
breakdowns: breakdowns || [],
interval,
startDate,
endDate,
range,
previous,
chartType: 'linear',
metric: 'sum',
},
date: clickedData.date,
});
},
}); });
} }
}
}, []);
const handleViewUsers = useCallback(() => { // Add Reference - always show
if (!clickedData || !projectId) return; items.push({
label: 'Add Reference',
icon: <BookmarkIcon size={16} />,
onClick: () => {
pushModal('AddReference', {
datetime: new Date(clickedData.date).toISOString(),
});
},
});
// Pass the chart data (which we already have) and the report config return items;
pushModal('ViewChartUsers', { },
chartData: data, [
report: { projectId,
projectId, data,
series: reportSeries, reportSeries,
breakdowns: breakdowns || [], breakdowns,
interval, interval,
startDate, startDate,
endDate, endDate,
range, range,
previous, previous,
chartType: 'linear', ],
metric: 'sum', );
},
date: clickedData.date,
});
setClickPosition(null);
setClickedData(null);
}, [
clickedData,
projectId,
data,
reportSeries,
breakdowns,
interval,
startDate,
endDate,
range,
previous,
]);
const handleAddReference = useCallback(() => {
if (!clickedData) return;
pushModal('AddReference', {
datetime: new Date(clickedData.date).toISOString(),
});
setClickPosition(null);
setClickedData(null);
}, [clickedData]);
return ( return (
<ReportChartTooltip.TooltipProvider references={references.data}> <ReportChartTooltip.TooltipProvider references={references.data}>
<div className={cn('h-full w-full', isEditMode && 'card p-4')}> <ChartClickMenu getMenuItems={getMenuItems}>
<DropdownMenu <div className={cn('h-full w-full', isEditMode && 'card p-4')}>
open={clickPosition !== null} <ResponsiveContainer>
onOpenChange={(open) => { <ComposedChart data={rechartData}>
if (!open) { <Customized component={calcStrokeDasharray} />
setClickPosition(null); <Line
setClickedData(null); dataKey="calcStrokeDasharray"
} legendType="none"
}} animationDuration={0}
> onAnimationEnd={handleAnimationEnd}
<DropdownMenuTrigger asChild>
<div
style={{
position: 'absolute',
left: clickPosition?.x ?? -9999,
top: clickPosition?.y ?? -9999,
pointerEvents: 'none',
}}
/>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onClick={handleViewUsers}>
<UsersIcon size={16} className="mr-2" />
View Users
</DropdownMenuItem>
<DropdownMenuItem onClick={handleAddReference}>
<BookmarkIcon size={16} className="mr-2" />
Add Reference
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<ResponsiveContainer>
<ComposedChart data={rechartData} onClick={handleChartClick}>
<Customized component={calcStrokeDasharray} />
<Line
dataKey="calcStrokeDasharray"
legendType="none"
animationDuration={0}
onAnimationEnd={handleAnimationEnd}
/>
<CartesianGrid
strokeDasharray="3 3"
horizontal={true}
vertical={false}
className="stroke-border"
/>
{references.data?.map((ref) => (
<ReferenceLine
key={ref.id}
x={ref.date.getTime()}
stroke={'oklch(from var(--foreground) l c h / 0.1)'}
strokeDasharray={'3 3'}
label={{
value: ref.title,
position: 'centerTop',
fill: '#334155',
fontSize: 12,
}}
fontSize={10}
/> />
))} <CartesianGrid
<YAxis strokeDasharray="3 3"
{...yAxisProps} horizontal={true}
domain={maxDomain ? [0, maxDomain] : undefined} vertical={false}
/> className="stroke-border"
<XAxis {...xAxisProps} /> />
{series.length > 1 && <Legend content={<CustomLegend />} />} {references.data?.map((ref) => (
<Tooltip content={<ReportChartTooltip.Tooltip />} /> <ReferenceLine
{/* {series.map((serie) => { key={ref.id}
const color = getChartColor(serie.index); x={ref.date.getTime()}
return ( stroke={'oklch(from var(--foreground) l c h / 0.1)'}
<React.Fragment key={serie.id}> strokeDasharray={'3 3'}
<defs> label={{
{isAreaStyle && ( value: ref.title,
<linearGradient position: 'centerTop',
id={`color${color}`} fill: '#334155',
x1="0" fontSize: 12,
y1="0" }}
x2="0" fontSize={10}
y2="1" />
> ))}
<stop offset="0%" stopColor={color} stopOpacity={0.8} /> <YAxis
<stop {...yAxisProps}
offset="100%" domain={maxDomain ? [0, maxDomain] : undefined}
stopColor={color} />
stopOpacity={0.1} <XAxis {...xAxisProps} />
/> {series.length > 1 && <Legend content={<CustomLegend />} />}
</linearGradient> <Tooltip content={<ReportChartTooltip.Tooltip />} />
)}
</defs> <defs>
<filter
id="rainbow-line-glow"
x="-20%"
y="-20%"
width="140%"
height="140%"
>
<feGaussianBlur stdDeviation="5" result="blur" />
<feComponentTransfer in="blur" result="dimmedBlur">
<feFuncA type="linear" slope="0.5" />
</feComponentTransfer>
<feComposite
in="SourceGraphic"
in2="dimmedBlur"
operator="over"
/>
</filter>
</defs>
{series.map((serie) => {
const color = getChartColor(serie.index);
return (
<Line <Line
dot={isAreaStyle && dataLength <= 8} key={serie.id}
dot={dataLength <= 8}
type={lineType} type={lineType}
name={serie.id} name={serie.id}
isAnimationActive={false} isAnimationActive={false}
@@ -324,100 +288,46 @@ export function Chart({ data }: Props) {
} }
// Use for legend // Use for legend
fill={color} fill={color}
filter={
series.length === 1
? 'url(#rainbow-line-glow)'
: undefined
}
/> />
{previous && ( );
<Line })}
type={lineType}
name={`${serie.id}:prev`}
isAnimationActive
dot={false}
strokeOpacity={0.3}
dataKey={`${serie.id}:prev:count`}
stroke={color}
// Use for legend
fill={color}
/>
)}
</React.Fragment>
);
})} */}
<defs> {/* Previous */}
<filter {previous
id="rainbow-line-glow" ? series.map((serie) => {
x="-20%" const color = getChartColor(serie.index);
y="-20%" return (
width="140%" <Line
height="140%" key={`${serie.id}:prev`}
> type={lineType}
<feGaussianBlur stdDeviation="5" result="blur" /> name={`${serie.id}:prev`}
<feComponentTransfer in="blur" result="dimmedBlur"> isAnimationActive
<feFuncA type="linear" slope="0.5" /> dot={false}
</feComponentTransfer> strokeOpacity={0.3}
<feComposite dataKey={`${serie.id}:prev:count`}
in="SourceGraphic" stroke={color}
in2="dimmedBlur" // Use for legend
operator="over" fill={color}
/> />
</filter> );
</defs> })
: null}
{series.map((serie) => { </ComposedChart>
const color = getChartColor(serie.index); </ResponsiveContainer>
return ( </div>
<Line {isEditMode && (
key={serie.id} <ReportTable
dot={dataLength <= 8} data={data}
type={lineType} visibleSeries={series}
name={serie.id} setVisibleSeries={setVisibleSeries}
isAnimationActive={false} />
strokeWidth={2} )}
dataKey={`${serie.id}:count`} </ChartClickMenu>
stroke={color}
strokeDasharray={
useDashedLastLine
? getStrokeDasharray(`${serie.id}:count`)
: undefined
}
// Use for legend
fill={color}
filter={
series.length === 1 ? 'url(#rainbow-line-glow)' : undefined
}
/>
);
})}
{/* Previous */}
{previous
? series.map((serie) => {
const color = getChartColor(serie.index);
return (
<Line
key={`${serie.id}:prev`}
type={lineType}
name={`${serie.id}:prev`}
isAnimationActive
dot={false}
strokeOpacity={0.3}
dataKey={`${serie.id}:prev:count`}
stroke={color}
// Use for legend
fill={color}
/>
);
})
: null}
</ComposedChart>
</ResponsiveContainer>
</div>
{isEditMode && (
<ReportTable
data={data}
visibleSeries={series}
setVisibleSeries={setVisibleSeries}
/>
)}
</ReportChartTooltip.TooltipProvider> </ReportChartTooltip.TooltipProvider>
); );
} }

View File

@@ -1,8 +1,36 @@
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'; import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
import type * as React from 'react'; import * as React from 'react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
export const VirtualScrollArea = React.forwardRef<
HTMLDivElement,
{
children: React.ReactNode;
className?: string;
}
>(({ children, className }, ref) => {
// The ref MUST point directly to the scrollable element
// This element MUST have:
// 1. overflow-y-auto (or overflow: auto)
// 2. A constrained height (via flex-1 min-h-0 or fixed height)
return (
<div
ref={ref}
className={cn('overflow-y-auto w-full', className)}
style={{
// Ensure height is constrained by flex parent
height: '100%',
maxHeight: '100%',
}}
>
{children}
</div>
);
});
VirtualScrollArea.displayName = 'VirtualScrollArea';
function ScrollArea({ function ScrollArea({
className, className,
children, children,

View File

@@ -0,0 +1,44 @@
import { ScrollArea, VirtualScrollArea } from '@/components/ui/scroll-area';
import { cn } from '@/utils/cn';
import { createContext, useContext, useRef } from 'react';
import { ModalContent } from './Container';
const ScrollableModalContext = createContext<{
scrollAreaRef: React.RefObject<HTMLDivElement | null>;
}>({
scrollAreaRef: { current: null },
});
export function useScrollableModal() {
return useContext(ScrollableModalContext);
}
export function ScrollableModal({
header,
footer,
children,
}: {
header: React.ReactNode;
footer?: React.ReactNode;
children: React.ReactNode;
}) {
const scrollAreaRef = useRef<HTMLDivElement>(null);
return (
<ScrollableModalContext.Provider value={{ scrollAreaRef }}>
<ModalContent className="flex !max-h-[90vh] flex-col p-0 gap-0">
<div className="flex-shrink-0 p-6">{header}</div>
<VirtualScrollArea
ref={scrollAreaRef}
className={cn(
'flex-1 min-h-0 w-full',
footer && 'border-b',
header && 'border-t',
)}
>
{children}
</VirtualScrollArea>
{footer && <div className="flex-shrink-0 p-6">{footer}</div>}
</ModalContent>
</ScrollableModalContext.Provider>
);
}

View File

@@ -1,5 +1,7 @@
import { ButtonContainer } from '@/components/button-container'; import { ProjectLink } from '@/components/links';
import { Button } from '@/components/ui/button'; import { ProfileAvatar } from '@/components/profiles/profile-avatar';
import { SerieIcon } from '@/components/report-chart/common/serie-icon';
import { DropdownMenuShortcut } from '@/components/ui/dropdown-menu';
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -9,119 +11,197 @@ import {
} from '@/components/ui/select'; } from '@/components/ui/select';
import { useTRPC } from '@/integrations/trpc/react'; import { useTRPC } from '@/integrations/trpc/react';
import type { IChartData } from '@/trpc/client'; import type { IChartData } from '@/trpc/client';
import type { IChartEvent, IChartInput } from '@openpanel/validation'; import { cn } from '@/utils/cn';
import { getProfileName } from '@/utils/getters';
import type { IChartInput } from '@openpanel/validation';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { UsersIcon } from 'lucide-react'; import { useVirtualizer } from '@tanstack/react-virtual';
import { useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { popModal } from '.'; import { popModal } from '.';
import { ModalContent, ModalHeader } from './Modal/Container'; import { ModalHeader } from './Modal/Container';
import { ScrollableModal, useScrollableModal } from './Modal/scrollable-modal';
interface ViewChartUsersProps { const ProfileItem = ({ profile }: { profile: any }) => {
console.log('ProfileItem', profile.id);
return (
<ProjectLink
preload={false}
href={`/profiles/${profile.id}`}
title={getProfileName(profile, false)}
className="col gap-2 rounded-lg border p-2 bg-card"
onClick={(e) => {
if (e.metaKey || e.ctrlKey || e.shiftKey) {
return;
}
popModal();
}}
>
<div className="row gap-2 items-center">
<ProfileAvatar {...profile} />
<div className="flex-1">
<div className="font-medium">{getProfileName(profile)}</div>
</div>
</div>
<div className="row gap-4 text-sm overflow-hidden">
{profile.properties.country && (
<div className="row gap-2 items-center">
<SerieIcon name={profile.properties.country} />
<span>
{profile.properties.country}
{profile.properties.city && ` / ${profile.properties.city}`}
</span>
</div>
)}
{profile.properties.os && (
<div className="row gap-2 items-center">
<SerieIcon name={profile.properties.os} />
<span>{profile.properties.os}</span>
</div>
)}
{profile.properties.browser && (
<div className="row gap-2 items-center">
<SerieIcon name={profile.properties.browser} />
<span>{profile.properties.browser}</span>
</div>
)}
</div>
</ProjectLink>
);
};
// Shared profile list component
function ProfileList({ profiles }: { profiles: any[] }) {
const ITEM_HEIGHT = 74;
const CONTAINER_PADDING = 20;
const ITEM_GAP = 5;
const { scrollAreaRef } = useScrollableModal();
const [isScrollReady, setIsScrollReady] = useState(false);
// Check if scroll container is ready
useEffect(() => {
if (scrollAreaRef.current) {
setIsScrollReady(true);
} else {
setIsScrollReady(false);
}
}, [scrollAreaRef]);
const virtualizer = useVirtualizer({
count: profiles.length,
getScrollElement: () => scrollAreaRef.current,
estimateSize: () => ITEM_HEIGHT + ITEM_GAP,
overscan: 5,
paddingStart: CONTAINER_PADDING,
paddingEnd: CONTAINER_PADDING,
});
// Re-measure when scroll container becomes available or profiles change
useEffect(() => {
if (isScrollReady && scrollAreaRef.current) {
// Small delay to ensure DOM is ready
const timeoutId = setTimeout(() => {
virtualizer.measure();
}, 0);
return () => clearTimeout(timeoutId);
}
}, [isScrollReady, profiles.length, virtualizer]);
if (profiles.length === 0) {
return (
<div className="flex items-center justify-center py-8">
<div className="text-muted-foreground">No users found</div>
</div>
);
}
const virtualItems = virtualizer.getVirtualItems();
return (
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{/* Only the visible items in the virtualizer, manually positioned to be in view */}
{virtualItems.map((virtualItem) => {
const profile = profiles[virtualItem.index];
return (
<div
key={profile.id}
data-index={virtualItem.index}
ref={virtualizer.measureElement}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`,
padding: `0px ${CONTAINER_PADDING}px ${ITEM_GAP}px`,
}}
>
<ProfileItem profile={profile} />
</div>
);
})}
</div>
);
}
// Chart-specific props and component
interface ChartUsersViewProps {
chartData: IChartData; chartData: IChartData;
report: IChartInput; report: IChartInput;
date: string; date: string;
} }
export default function ViewChartUsers({ function ChartUsersView({ chartData, report, date }: ChartUsersViewProps) {
chartData,
report,
date,
}: ViewChartUsersProps) {
const trpc = useTRPC(); const trpc = useTRPC();
const [selectedSerieId, setSelectedSerieId] = useState<string | null>(
// Group series by base event/formula (ignoring breakdowns) report.series[0]?.id || null,
const baseSeries = useMemo(() => { );
const grouped = new Map< const [selectedBreakdownId, setSelectedBreakdownId] = useState<string | null>(
string,
{
baseName: string;
baseEventId: string;
reportSerie: IChartInput['series'][0] | undefined;
breakdownSeries: Array<{
serie: IChartData['series'][0];
breakdowns: Record<string, string> | undefined;
}>;
}
>();
chartData.series.forEach((serie) => {
const baseEventId = serie.event.id || '';
const baseName = serie.names[0] || 'Unnamed Serie';
if (!grouped.has(baseEventId)) {
const reportSerie = report.series.find((ss) => ss.id === baseEventId);
grouped.set(baseEventId, {
baseName,
baseEventId,
reportSerie,
breakdownSeries: [],
});
}
const group = grouped.get(baseEventId);
if (!group) return;
// Extract breakdowns from serie.event.breakdowns (set in format.ts)
const breakdowns = (serie.event as any).breakdowns;
group.breakdownSeries.push({
serie,
breakdowns,
});
});
return Array.from(grouped.values());
}, [chartData.series, report.series, report.breakdowns]);
const [selectedBaseSerieId, setSelectedBaseSerieId] = useState<string | null>(
null, null,
); );
const [selectedBreakdownIndex, setSelectedBreakdownIndex] = useState<
number | null
>(null);
const selectedBaseSerie = useMemo( const selectedReportSerie = useMemo(
() => baseSeries.find((bs) => bs.baseEventId === selectedBaseSerieId), () => report.series.find((s) => s.id === selectedSerieId),
[baseSeries, selectedBaseSerieId], [report.series, selectedSerieId],
); );
// Get all chart series that match the selected report serie
const matchingChartSeries = useMemo(() => {
if (!selectedSerieId || !chartData) return [];
return chartData.series.filter((s) => s.event.id === selectedSerieId);
}, [chartData?.series, selectedSerieId]);
const selectedBreakdown = useMemo(() => { const selectedBreakdown = useMemo(() => {
if ( if (!selectedBreakdownId) return null;
!selectedBaseSerie || return matchingChartSeries.find((s) => s.id === selectedBreakdownId);
selectedBreakdownIndex === null || }, [matchingChartSeries, selectedBreakdownId]);
!selectedBaseSerie.breakdownSeries[selectedBreakdownIndex]
) {
return null;
}
return selectedBaseSerie.breakdownSeries[selectedBreakdownIndex];
}, [selectedBaseSerie, selectedBreakdownIndex]);
// Reset breakdown selection when base serie changes // Reset breakdown selection when serie changes
const handleBaseSerieChange = (value: string) => { const handleSerieChange = (value: string) => {
setSelectedBaseSerieId(value); setSelectedSerieId(value);
setSelectedBreakdownIndex(null); setSelectedBreakdownId(null);
}; };
const selectedSerie = selectedBreakdown || selectedBaseSerie;
const profilesQuery = useQuery( const profilesQuery = useQuery(
trpc.chart.getProfiles.queryOptions( trpc.chart.getProfiles.queryOptions(
{ {
projectId: report.projectId, projectId: report.projectId,
date: date, date: date,
series: series:
selectedSerie && selectedReportSerie && selectedReportSerie.type === 'event'
selectedBaseSerie?.reportSerie && ? [selectedReportSerie]
selectedBaseSerie.reportSerie.type === 'event'
? [selectedBaseSerie.reportSerie]
: [], : [],
breakdowns: selectedBreakdown?.breakdowns, breakdowns: selectedBreakdown?.event.breakdowns,
interval: report.interval, interval: report.interval,
}, },
{ {
enabled: enabled: !!selectedReportSerie && selectedReportSerie.type === 'event',
!!selectedSerie &&
!!selectedBaseSerie?.reportSerie &&
selectedBaseSerie.reportSerie.type === 'event',
}, },
), ),
); );
@@ -129,118 +209,191 @@ export default function ViewChartUsers({
const profiles = profilesQuery.data ?? []; const profiles = profilesQuery.data ?? [];
return ( return (
<ModalContent> <ScrollableModal
<ModalHeader title="View Users" /> header={
<p className="text-sm text-muted-foreground mb-4"> <div>
Users who performed actions on {new Date(date).toLocaleDateString()} <ModalHeader
</p> title="View Users"
<div className="flex flex-col gap-4"> text={`Users who performed actions on ${new Date(date).toLocaleDateString()}`}
{baseSeries.length > 0 && ( />
<div className="flex flex-col gap-3"> {report.series.length > 0 && (
<div className="flex items-center gap-2"> <div className="col md:row gap-2">
<label className="text-sm font-medium">Serie:</label>
<Select <Select
value={selectedBaseSerieId || ''} value={selectedSerieId || ''}
onValueChange={handleBaseSerieChange} onValueChange={handleSerieChange}
> >
<SelectTrigger className="w-[200px]"> <SelectTrigger className="flex-1">
<SelectValue placeholder="Select Serie" /> <SelectValue placeholder="Select Serie" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{baseSeries.map((baseSerie) => ( {report.series.map((serie) => (
<SelectItem <SelectItem key={serie.id} value={serie.id || ''}>
key={baseSerie.baseEventId} {serie.type === 'event'
value={baseSerie.baseEventId} ? serie.displayName || serie.name
> : serie.displayName || 'Formula'}
{baseSerie.baseName}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</div>
{selectedBaseSerie && {matchingChartSeries.length > 1 && (
selectedBaseSerie.breakdownSeries.length > 1 && ( <Select
<div className="flex items-center gap-2"> value={selectedBreakdownId || ''}
<label className="text-sm font-medium">Breakdown:</label> onValueChange={(value) => setSelectedBreakdownId(value)}
<Select >
value={selectedBreakdownIndex?.toString() || ''} <SelectTrigger className="flex-1">
onValueChange={(value) => <SelectValue placeholder="Select Breakdown" />
setSelectedBreakdownIndex( </SelectTrigger>
value ? Number.parseInt(value, 10) : null, <SelectContent>
) {matchingChartSeries
} .sort((a, b) => b.metrics.sum - a.metrics.sum)
> .map((serie) => (
<SelectTrigger className="w-[200px]"> <SelectItem key={serie.id} value={serie.id}>
<SelectValue placeholder="All Breakdowns" /> {Object.values(serie.event.breakdowns ?? {}).join(
</SelectTrigger> ', ',
<SelectContent> )}
<SelectItem value="">All Breakdowns</SelectItem> <DropdownMenuShortcut className="ml-auto">
{selectedBaseSerie.breakdownSeries.map((bdSerie, idx) => ( ({serie.data.find((d) => d.date === date)?.count})
<SelectItem </DropdownMenuShortcut>
key={bdSerie.serie.id}
value={idx.toString()}
>
{bdSerie.serie.names.slice(1).join(' > ') ||
'No Breakdown'}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</div>
)} )}
</div> </div>
)} )}
</div>
}
>
<div className="col">
{profilesQuery.isLoading ? ( {profilesQuery.isLoading ? (
<div className="flex items-center justify-center py-8"> <div className="flex items-center justify-center py-8">
<div className="text-muted-foreground">Loading users...</div> <div className="text-muted-foreground">Loading users...</div>
</div> </div>
) : profiles.length === 0 ? (
<div className="flex items-center justify-center py-8">
<div className="text-muted-foreground">No users found</div>
</div>
) : ( ) : (
<div className="max-h-[60vh] overflow-y-auto"> <ProfileList profiles={profiles} />
<div className="flex flex-col gap-2">
{profiles.map((profile) => (
<div
key={profile.id}
className="flex items-center gap-3 rounded-lg border p-3"
>
{profile.avatar ? (
<img
src={profile.avatar}
alt={profile.firstName || profile.email}
className="size-10 rounded-full"
/>
) : (
<div className="flex size-10 items-center justify-center rounded-full bg-muted">
<UsersIcon size={20} />
</div>
)}
<div className="flex-1">
<div className="font-medium">
{profile.firstName || profile.lastName
? `${profile.firstName || ''} ${profile.lastName || ''}`.trim()
: profile.email || 'Anonymous'}
</div>
{profile.email && (
<div className="text-sm text-muted-foreground">
{profile.email}
</div>
)}
</div>
</div>
))}
</div>
</div>
)} )}
<ButtonContainer>
<Button type="button" variant="outline" onClick={() => popModal()}>
Close
</Button>
</ButtonContainer>
</div> </div>
</ModalContent> </ScrollableModal>
);
}
// Funnel-specific props and component
interface FunnelUsersViewProps {
report: IChartInput;
stepIndex: number;
}
function FunnelUsersView({ report, stepIndex }: FunnelUsersViewProps) {
const trpc = useTRPC();
const [showDropoffs, setShowDropoffs] = useState(false);
const profilesQuery = useQuery(
trpc.chart.getFunnelProfiles.queryOptions(
{
projectId: report.projectId,
startDate: report.startDate!,
endDate: report.endDate!,
range: report.range,
series: report.series,
stepIndex: stepIndex,
showDropoffs: showDropoffs,
funnelWindow: report.funnelWindow,
funnelGroup: report.funnelGroup,
breakdowns: report.breakdowns,
},
{
enabled: stepIndex !== undefined,
},
),
);
const profiles = profilesQuery.data ?? [];
const isLastStep = stepIndex === report.series.length - 1;
return (
<ScrollableModal
header={
<div className="flex flex-col gap-2">
<ModalHeader
title="View Users"
text={
showDropoffs
? `Users who dropped off after step ${stepIndex + 1} of ${report.series.length}`
: `Users who completed step ${stepIndex + 1} of ${report.series.length} in the funnel`
}
/>
{!isLastStep && (
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setShowDropoffs(false)}
className={cn(
'px-3 py-1.5 text-sm rounded-md transition-colors',
!showDropoffs
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground hover:bg-muted/80',
)}
>
Completed
</button>
<button
type="button"
onClick={() => setShowDropoffs(true)}
className={cn(
'px-3 py-1.5 text-sm rounded-md transition-colors',
showDropoffs
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground hover:bg-muted/80',
)}
>
Dropped Off
</button>
</div>
)}
</div>
}
>
<div className="flex flex-col gap-4">
{profilesQuery.isLoading ? (
<div className="flex items-center justify-center py-8">
<div className="text-muted-foreground">Loading users...</div>
</div>
) : (
<ProfileList profiles={profiles} />
)}
</div>
</ScrollableModal>
);
}
// Union type for props
type ViewChartUsersProps =
| {
type: 'chart';
chartData: IChartData;
report: IChartInput;
date: string;
}
| {
type: 'funnel';
report: IChartInput;
stepIndex: number;
};
// Main component that routes to the appropriate view
export default function ViewChartUsers(props: ViewChartUsersProps) {
if (props.type === 'funnel') {
return (
<FunnelUsersView report={props.report} stepIndex={props.stepIndex} />
);
}
return (
<ChartUsersView
chartData={props.chartData}
report={props.report}
date={props.date}
/>
); );
} }

View File

@@ -339,4 +339,38 @@ button {
.animate-ping-slow { .animate-ping-slow {
animation: ping-slow 2s cubic-bezier(0, 0, 0.2, 1) infinite; animation: ping-slow 2s cubic-bezier(0, 0, 0.2, 1) infinite;
}
.scrollarea {
position: relative;
width: 100%;
height: 300px; /* set any height */
overflow: hidden; /* hide native scrollbars */
}
.scrollarea-content {
height: 100%;
width: 100%;
overflow-y: scroll;
padding-right: 16px; /* preserve space for custom scrollbar */
scrollbar-width: none; /* hide Firefox scrollbar */
}
.scrollarea-content::-webkit-scrollbar {
width: 8px; /* size of custom scrollbar */
}
.scrollarea-content::-webkit-scrollbar-track {
background: transparent; /* no visible track, like shadcn */
}
.scrollarea-content::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.2);
border-radius: 9999px; /* fully rounded */
transition: background 0.2s;
}
.scrollarea-content:hover::-webkit-scrollbar-thumb,
.scrollarea-content:active::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.4); /* darken on hover/scroll */
} }

View File

@@ -507,12 +507,11 @@ export function getChartStartEndDate(
}: Pick<IChartInput, 'endDate' | 'startDate' | 'range'>, }: Pick<IChartInput, 'endDate' | 'startDate' | 'range'>,
timezone: string, timezone: string,
) { ) {
const ranges = getDatesFromRange(range, timezone);
if (startDate && endDate) { if (startDate && endDate) {
return { startDate: startDate, endDate: endDate }; return { startDate: startDate, endDate: endDate };
} }
const ranges = getDatesFromRange(range, timezone);
if (!startDate && endDate) { if (!startDate && endDate) {
return { startDate: ranges.startDate, endDate: endDate }; return { startDate: ranges.startDate, endDate: endDate };
} }

View File

@@ -1,5 +1,9 @@
import { ifNaN } from '@openpanel/common'; import { ifNaN } from '@openpanel/common';
import type { IChartEvent, IChartInput } from '@openpanel/validation'; import type {
IChartEvent,
IChartEventItem,
IChartInput,
} from '@openpanel/validation';
import { last, reverse, uniq } from 'ramda'; import { last, reverse, uniq } from 'ramda';
import sqlstring from 'sqlstring'; import sqlstring from 'sqlstring';
import { ch } from '../clickhouse/client'; import { ch } from '../clickhouse/client';
@@ -14,13 +18,13 @@ import {
export class FunnelService { export class FunnelService {
constructor(private client: typeof ch) {} constructor(private client: typeof ch) {}
private getFunnelGroup(group?: string) { getFunnelGroup(group?: string): [string, string] {
return group === 'profile_id' return group === 'profile_id'
? [`COALESCE(nullIf(s.pid, ''), profile_id)`, 'profile_id'] ? [`COALESCE(nullIf(s.pid, ''), profile_id)`, 'profile_id']
: ['session_id', 'session_id']; : ['session_id', 'session_id'];
} }
private getFunnelConditions(events: IChartEvent[] = []) { getFunnelConditions(events: IChartEvent[] = []): string[] {
return events.map((event) => { return events.map((event) => {
const { sb, getWhere } = createSqlBuilder(); const { sb, getWhere } = createSqlBuilder();
sb.where = getEventFiltersWhereClause(event.filters); sb.where = getEventFiltersWhereClause(event.filters);
@@ -29,6 +33,70 @@ export class FunnelService {
}); });
} }
buildFunnelCte({
projectId,
startDate,
endDate,
eventSeries,
funnelWindowMilliseconds,
group,
timezone,
additionalSelects = [],
additionalGroupBy = [],
}: {
projectId: string;
startDate: string;
endDate: string;
eventSeries: IChartEvent[];
funnelWindowMilliseconds: number;
group: [string, string];
timezone: string;
additionalSelects?: string[];
additionalGroupBy?: string[];
}) {
const funnels = this.getFunnelConditions(eventSeries);
return clix(this.client, timezone)
.select([
`${group[0]} AS ${group[1]}`,
...additionalSelects,
`windowFunnel(${funnelWindowMilliseconds}, 'strict_increase')(toUInt64(toUnixTimestamp64Milli(created_at)), ${funnels.join(', ')}) AS level`,
])
.from(TABLE_NAMES.events, false)
.where('project_id', '=', projectId)
.where('created_at', 'BETWEEN', [
clix.datetime(startDate, 'toDateTime'),
clix.datetime(endDate, 'toDateTime'),
])
.where(
'name',
'IN',
eventSeries.map((e) => e.name),
)
.groupBy([group[1], ...additionalGroupBy]);
}
buildSessionsCte({
projectId,
startDate,
endDate,
timezone,
}: {
projectId: string;
startDate: string;
endDate: string;
timezone: string;
}) {
return clix(this.client, timezone)
.select(['profile_id as pid', 'id as sid'])
.from(TABLE_NAMES.sessions)
.where('project_id', '=', projectId)
.where('created_at', 'BETWEEN', [
clix.datetime(startDate, 'toDateTime'),
clix.datetime(endDate, 'toDateTime'),
]);
}
private fillFunnel( private fillFunnel(
funnel: { level: number; count: number }[], funnel: { level: number; count: number }[],
steps: number, steps: number,
@@ -116,14 +184,16 @@ export class FunnelService {
funnelGroup, funnelGroup,
breakdowns = [], breakdowns = [],
timezone = 'UTC', timezone = 'UTC',
}: IChartInput & { timezone: string }) { }: IChartInput & { timezone: string; events?: IChartEvent[] }) {
if (!startDate || !endDate) { if (!startDate || !endDate) {
throw new Error('startDate and endDate are required'); throw new Error('startDate and endDate are required');
} }
// Use series if available, otherwise fall back to events (backward compat) // Use series if available, otherwise fall back to events (backward compat)
const eventSeries = (series ?? events ?? []).filter( const rawSeries = (series ?? events ?? []) as IChartEventItem[];
(item): item is IChartEvent => item.type === 'event', const eventSeries = rawSeries.filter(
(item): item is IChartEventItem & { type: 'event' } =>
item.type === 'event',
) as IChartEvent[]; ) as IChartEvent[];
if (eventSeries.length === 0) { if (eventSeries.length === 0) {
@@ -133,7 +203,6 @@ export class FunnelService {
const funnelWindowSeconds = funnelWindow * 3600; const funnelWindowSeconds = funnelWindow * 3600;
const funnelWindowMilliseconds = funnelWindowSeconds * 1000; const funnelWindowMilliseconds = funnelWindowSeconds * 1000;
const group = this.getFunnelGroup(funnelGroup); const group = this.getFunnelGroup(funnelGroup);
const funnels = this.getFunnelConditions(eventSeries);
const profileFilters = this.getProfileFilters(eventSeries); const profileFilters = this.getProfileFilters(eventSeries);
const anyFilterOnProfile = profileFilters.length > 0; const anyFilterOnProfile = profileFilters.length > 0;
const anyBreakdownOnProfile = breakdowns.some((b) => const anyBreakdownOnProfile = breakdowns.some((b) =>
@@ -141,26 +210,22 @@ export class FunnelService {
); );
// Create the funnel CTE // Create the funnel CTE
const funnelCte = clix(this.client, timezone) const breakdownSelects = breakdowns.map(
.select([ (b, index) => `${getSelectPropertyKey(b.name)} as b_${index}`,
`${group[0]} AS ${group[1]}`, );
...breakdowns.map( const breakdownGroupBy = breakdowns.map((b, index) => `b_${index}`);
(b, index) => `${getSelectPropertyKey(b.name)} as b_${index}`,
), const funnelCte = this.buildFunnelCte({
`windowFunnel(${funnelWindowMilliseconds}, 'strict_increase')(toUInt64(toUnixTimestamp64Milli(created_at)), ${funnels.join(', ')}) AS level`, projectId,
]) startDate,
.from(TABLE_NAMES.events, false) endDate,
.where('project_id', '=', projectId) eventSeries,
.where('created_at', 'BETWEEN', [ funnelWindowMilliseconds,
clix.datetime(startDate, 'toDateTime'), group,
clix.datetime(endDate, 'toDateTime'), timezone,
]) additionalSelects: breakdownSelects,
.where( additionalGroupBy: breakdownGroupBy,
'name', });
'IN',
eventSeries.map((e) => e.name),
)
.groupBy([group[1], ...breakdowns.map((b, index) => `b_${index}`)]);
if (anyFilterOnProfile || anyBreakdownOnProfile) { if (anyFilterOnProfile || anyBreakdownOnProfile) {
funnelCte.leftJoin( funnelCte.leftJoin(
@@ -173,15 +238,12 @@ export class FunnelService {
// Create the sessions CTE if needed // Create the sessions CTE if needed
const sessionsCte = const sessionsCte =
group[0] !== 'session_id' group[0] !== 'session_id'
? clix(this.client, timezone) ? this.buildSessionsCte({
// Important to have unique field names to avoid ambiguity in the main query projectId,
.select(['profile_id as pid', 'id as sid']) startDate,
.from(TABLE_NAMES.sessions) endDate,
.where('project_id', '=', projectId) timezone,
.where('created_at', 'BETWEEN', [ })
clix.datetime(startDate, 'toDateTime'),
clix.datetime(endDate, 'toDateTime'),
])
: null; : null;
// Base funnel query with CTEs // Base funnel query with CTEs

View File

@@ -23,6 +23,7 @@ import {
getSettingsForProject, getSettingsForProject,
} from '@openpanel/db'; } from '@openpanel/db';
import { import {
type IChartEvent,
zChartEvent, zChartEvent,
zChartEventFilter, zChartEventFilter,
zChartInput, zChartInput,
@@ -621,6 +622,122 @@ export const chartRouter = createTRPCRouter({
const ids = profileIds.map((p) => p.profile_id).filter(Boolean); const ids = profileIds.map((p) => p.profile_id).filter(Boolean);
const profiles = await getProfilesCached(ids, projectId); const profiles = await getProfilesCached(ids, projectId);
return profiles;
}),
getFunnelProfiles: protectedProcedure
.input(
z.object({
projectId: z.string(),
startDate: z.string().nullish(),
endDate: z.string().nullish(),
series: zChartSeries,
stepIndex: z.number().describe('0-based index of the funnel step'),
showDropoffs: z
.boolean()
.optional()
.default(false)
.describe(
'If true, show users who dropped off at this step. If false, show users who completed at least this step.',
),
funnelWindow: z.number().optional(),
funnelGroup: z.string().optional(),
breakdowns: z.array(z.object({ name: z.string() })).optional(),
range: zRange,
}),
)
.query(async ({ input }) => {
const { timezone } = await getSettingsForProject(input.projectId);
const {
projectId,
series,
stepIndex,
showDropoffs = false,
funnelWindow,
funnelGroup,
breakdowns = [],
} = input;
const { startDate, endDate } = getChartStartEndDate(input, timezone);
// stepIndex is 0-based, but level is 1-based, so we need level >= stepIndex + 1
const targetLevel = stepIndex + 1;
const eventSeries = series.filter(
(item): item is typeof item & { type: 'event' } =>
item.type === 'event',
);
if (eventSeries.length === 0) {
throw new Error('At least one event series is required');
}
const funnelWindowSeconds = (funnelWindow || 24) * 3600;
const funnelWindowMilliseconds = funnelWindowSeconds * 1000;
// Use funnel service methods
const group = funnelService.getFunnelGroup(funnelGroup);
// Create sessions CTE if needed
const sessionsCte =
group[0] !== 'session_id'
? funnelService.buildSessionsCte({
projectId,
startDate,
endDate,
timezone,
})
: null;
// Create funnel CTE using funnel service
const funnelCte = funnelService.buildFunnelCte({
projectId,
startDate,
endDate,
eventSeries: eventSeries as IChartEvent[],
funnelWindowMilliseconds,
group,
timezone,
additionalSelects: ['profile_id'],
additionalGroupBy: ['profile_id'],
});
// Build main query
const query = clix(ch, timezone);
if (sessionsCte) {
funnelCte.leftJoin('sessions s', 's.sid = events.session_id');
query.with('sessions', sessionsCte);
}
query.with('funnel', funnelCte);
// Get distinct profile IDs
query
.select(['DISTINCT profile_id'])
.from('funnel')
.where('level', '!=', 0);
if (showDropoffs) {
// Show users who dropped off at this step (completed this step but not the next)
query.where('level', '=', targetLevel);
} else {
// Show users who completed at least this step
query.where('level', '>=', targetLevel);
}
const profileIdsResult = (await query.execute()) as {
profile_id: string;
}[];
if (profileIdsResult.length === 0) {
return [];
}
// Fetch profile details
const ids = profileIdsResult.map((p) => p.profile_id).filter(Boolean);
const profiles = await getProfilesCached(ids, projectId);
return profiles; return profiles;
}), }),
}); });