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 { useQuery } from '@tanstack/react-query';
import { isSameDay, isSameHour, isSameMonth, isSameWeek } from 'date-fns';
import { BookmarkIcon, UsersIcon } from 'lucide-react';
import { last } from 'ramda';
import { useCallback } from 'react';
import {
@@ -25,6 +26,10 @@ import {
import { useDashedStroke } from '@/hooks/use-dashed-stroke';
import { useXAxisProps, useYAxisProps } from '../common/axis';
import {
ChartClickMenu,
type ChartClickMenuItem,
} from '../common/chart-click-menu';
import { ReportChartTooltip } from '../common/report-chart-tooltip';
import { ReportTable } from '../common/report-table';
import { SerieIcon } from '../common/serie-icon';
@@ -45,6 +50,8 @@ export function Chart({ data }: Props) {
endDate,
range,
lineType,
series: reportSeries,
breakdowns,
},
isEditMode,
options: { hideXAxis, hideYAxis },
@@ -126,16 +133,66 @@ export function Chart({ data }: Props) {
interval,
});
const handleChartClick = useCallback((e: any) => {
if (e?.activePayload?.[0]) {
const clickedData = e.activePayload[0].payload;
if (clickedData.date) {
const getMenuItems = useCallback(
(e: any, clickedData: any): ChartClickMenuItem[] => {
const items: ChartClickMenuItem[] = [];
if (!clickedData?.date) {
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 } =
useDashedStroke({
@@ -144,9 +201,10 @@ export function Chart({ data }: Props) {
return (
<ReportChartTooltip.TooltipProvider references={references.data}>
<ChartClickMenu getMenuItems={getMenuItems}>
<div className={cn('h-full w-full', isEditMode && 'card p-4')}>
<ResponsiveContainer>
<ComposedChart data={rechartData} onClick={handleChartClick}>
<ComposedChart data={rechartData}>
<Customized component={calcStrokeDasharray} />
<Line
dataKey="calcStrokeDasharray"
@@ -244,6 +302,7 @@ export function Chart({ data }: Props) {
setVisibleSeries={setVisibleSeries}
/>
)}
</ChartClickMenu>
</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 { Button } from '@/components/ui/button';
import { pushModal } from '@/modals';
import type { RouterOutputs } from '@/trpc/client';
import { cn } from '@/utils/cn';
import { ChevronRightIcon, InfoIcon } from 'lucide-react';
import { ChevronRightIcon, InfoIcon, UsersIcon } from 'lucide-react';
import { alphabetIds } from '@openpanel/constants';
@@ -23,6 +25,7 @@ import {
} from 'recharts';
import { useXAxisProps, useYAxisProps } from '../common/axis';
import { PreviousDiffIndicatorPure } from '../common/previous-diff-indicator';
import { useReportChartContext } from '../context';
type Props = {
data: {
@@ -113,11 +116,50 @@ function ChartName({
export function Tables({
data: {
current: { steps, mostDropoffsStep, lastStep, breakdowns },
previous,
previous: previousData,
},
}: Props) {
const number = useNumber();
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 (
<div className={cn('col @container divide-y divide-border card')}>
{hasHeader && <ChartName breakdowns={breakdowns} className="p-4 py-3" />}
@@ -128,11 +170,11 @@ export function Tables({
label="Conversion"
value={number.formatWithUnit(lastStep?.percent / 100, '%')}
enhancer={
previous && (
previousData && (
<PreviousDiffIndicatorPure
{...getPreviousMetric(
lastStep?.percent,
previous.lastStep?.percent,
previousData.lastStep?.percent,
)}
/>
)
@@ -143,11 +185,11 @@ export function Tables({
label="Completed"
value={number.format(lastStep?.count)}
enhancer={
previous && (
previousData && (
<PreviousDiffIndicatorPure
{...getPreviousMetric(
lastStep?.count,
previous.lastStep?.count,
previousData.lastStep?.count,
)}
/>
)
@@ -238,6 +280,28 @@ export function Tables({
className: 'text-right font-mono font-semibold',
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>

View File

@@ -7,6 +7,7 @@ import type { IChartData } from '@/trpc/client';
import { cn } from '@/utils/cn';
import { getChartColor } from '@/utils/theme';
import { useQuery } from '@tanstack/react-query';
import { BookmarkIcon, UsersIcon } from 'lucide-react';
import React, { useCallback } from 'react';
import {
Bar,
@@ -20,6 +21,10 @@ import {
} from 'recharts';
import { useXAxisProps, useYAxisProps } from '../common/axis';
import {
ChartClickMenu,
type ChartClickMenuItem,
} from '../common/chart-click-menu';
import { ReportChartTooltip } from '../common/report-chart-tooltip';
import { ReportTable } from '../common/report-table';
import { useReportChartContext } from '../context';
@@ -47,7 +52,16 @@ function BarHover({ x, y, width, height, top, left, right, bottom }: any) {
export function Chart({ data }: Props) {
const {
isEditMode,
report: { previous, interval, projectId, startDate, endDate, range },
report: {
previous,
interval,
projectId,
startDate,
endDate,
range,
series: reportSeries,
breakdowns,
},
options: { hideXAxis, hideYAxis },
} = useReportChartContext();
const trpc = useTRPC();
@@ -74,22 +88,73 @@ export function Chart({ data }: Props) {
interval,
});
const handleChartClick = useCallback((e: any) => {
if (e?.activePayload?.[0]) {
const clickedData = e.activePayload[0].payload;
if (clickedData.date) {
const getMenuItems = useCallback(
(e: any, clickedData: any): ChartClickMenuItem[] => {
const items: ChartClickMenuItem[] = [];
if (!clickedData?.date) {
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 (
<ReportChartTooltip.TooltipProvider references={references.data}>
<ChartClickMenu getMenuItems={getMenuItems}>
<div className={cn('h-full w-full', isEditMode && 'card p-4')}>
<ResponsiveContainer>
<BarChart data={rechartData} onClick={handleChartClick}>
<BarChart data={rechartData}>
<CartesianGrid
strokeDasharray="3 3"
vertical={false}
@@ -152,6 +217,7 @@ export function Chart({ data }: Props) {
setVisibleSeries={setVisibleSeries}
/>
)}
</ChartClickMenu>
</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 { useVisibleSeries } from '@/hooks/use-visible-series';
import { useTRPC } from '@/integrations/trpc/react';
@@ -11,12 +5,11 @@ import { pushModal } from '@/modals';
import type { IChartData } from '@/trpc/client';
import { cn } from '@/utils/cn';
import { getChartColor } from '@/utils/theme';
import type { IChartEvent } from '@openpanel/validation';
import { useQuery } from '@tanstack/react-query';
import { isSameDay, isSameHour, isSameMonth, isSameWeek } from 'date-fns';
import { BookmarkIcon, UsersIcon } from 'lucide-react';
import { last } from 'ramda';
import { useCallback, useState } from 'react';
import { useCallback } from 'react';
import {
CartesianGrid,
ComposedChart,
@@ -32,6 +25,10 @@ import {
import { useDashedStroke } from '@/hooks/use-dashed-stroke';
import { useXAxisProps, useYAxisProps } from '../common/axis';
import {
ChartClickMenu,
type ChartClickMenuItem,
} from '../common/chart-click-menu';
import { ReportChartTooltip } from '../common/report-chart-tooltip';
import { ReportTable } from '../common/report-table';
import { SerieIcon } from '../common/serie-icon';
@@ -58,14 +55,6 @@ export function Chart({ data }: Props) {
isEditMode,
options: { hideXAxis, hideYAxis, maxDomain },
} = 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 trpc = useTRPC();
const references = useQuery(
@@ -146,13 +135,17 @@ export function Chart({ data }: Props) {
hide: hideYAxis,
});
const handleChartClick = useCallback((e: any) => {
if (e?.activePayload?.[0]) {
const payload = e.activePayload[0].payload;
const activeCoordinate = e.activeCoordinate;
if (payload.date) {
// Find the first valid serie ID from activePayload (skip calcStrokeDasharray)
const validPayload = e.activePayload.find(
const getMenuItems = useCallback(
(e: any, clickedData: any): ChartClickMenuItem[] => {
const items: ChartClickMenuItem[] = [];
if (!clickedData?.date) {
return items;
}
// Extract serie ID from the click event if needed
// activePayload is an array of payload objects
const validPayload = e.activePayload?.find(
(p: any) =>
p.dataKey &&
p.dataKey !== 'calcStrokeDasharray' &&
@@ -161,23 +154,14 @@ export function Chart({ data }: Props) {
);
const serieId = validPayload?.dataKey?.toString().replace(':count', '');
setClickedData({
date: payload.date,
serieId,
});
setClickPosition({
x: activeCoordinate?.x ?? 0,
y: activeCoordinate?.y ?? 0,
});
}
}
}, []);
const handleViewUsers = useCallback(() => {
if (!clickedData || !projectId) return;
// Pass the chart data (which we already have) and the report config
// 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,
@@ -193,10 +177,24 @@ export function Chart({ data }: Props) {
},
date: clickedData.date,
});
setClickPosition(null);
setClickedData(null);
}, [
clickedData,
},
});
}
// 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,
@@ -206,52 +204,15 @@ export function Chart({ data }: Props) {
endDate,
range,
previous,
]);
const handleAddReference = useCallback(() => {
if (!clickedData) return;
pushModal('AddReference', {
datetime: new Date(clickedData.date).toISOString(),
});
setClickPosition(null);
setClickedData(null);
}, [clickedData]);
],
);
return (
<ReportChartTooltip.TooltipProvider references={references.data}>
<ChartClickMenu getMenuItems={getMenuItems}>
<div className={cn('h-full w-full', isEditMode && 'card p-4')}>
<DropdownMenu
open={clickPosition !== null}
onOpenChange={(open) => {
if (!open) {
setClickPosition(null);
setClickedData(null);
}
}}
>
<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}>
<ComposedChart data={rechartData}>
<Customized component={calcStrokeDasharray} />
<Line
dataKey="calcStrokeDasharray"
@@ -287,60 +248,6 @@ export function Chart({ data }: Props) {
<XAxis {...xAxisProps} />
{series.length > 1 && <Legend content={<CustomLegend />} />}
<Tooltip content={<ReportChartTooltip.Tooltip />} />
{/* {series.map((serie) => {
const color = getChartColor(serie.index);
return (
<React.Fragment key={serie.id}>
<defs>
{isAreaStyle && (
<linearGradient
id={`color${color}`}
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop offset="0%" stopColor={color} stopOpacity={0.8} />
<stop
offset="100%"
stopColor={color}
stopOpacity={0.1}
/>
</linearGradient>
)}
</defs>
<Line
dot={isAreaStyle && dataLength <= 8}
type={lineType}
name={serie.id}
isAnimationActive={false}
strokeWidth={2}
dataKey={`${serie.id}:count`}
stroke={color}
strokeDasharray={
useDashedLastLine
? getStrokeDasharray(`${serie.id}:count`)
: undefined
}
// Use for legend
fill={color}
/>
{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>
<filter
@@ -382,7 +289,9 @@ export function Chart({ data }: Props) {
// Use for legend
fill={color}
filter={
series.length === 1 ? 'url(#rainbow-line-glow)' : undefined
series.length === 1
? 'url(#rainbow-line-glow)'
: undefined
}
/>
);
@@ -418,6 +327,7 @@ export function Chart({ data }: Props) {
setVisibleSeries={setVisibleSeries}
/>
)}
</ChartClickMenu>
</ReportChartTooltip.TooltipProvider>
);
}

View File

@@ -1,8 +1,36 @@
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';
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({
className,
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 { Button } from '@/components/ui/button';
import { ProjectLink } from '@/components/links';
import { ProfileAvatar } from '@/components/profiles/profile-avatar';
import { SerieIcon } from '@/components/report-chart/common/serie-icon';
import { DropdownMenuShortcut } from '@/components/ui/dropdown-menu';
import {
Select,
SelectContent,
@@ -9,119 +11,197 @@ import {
} from '@/components/ui/select';
import { useTRPC } from '@/integrations/trpc/react';
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 { UsersIcon } from 'lucide-react';
import { useMemo, useState } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import { useEffect, useMemo, useState } from 'react';
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;
report: IChartInput;
date: string;
}
export default function ViewChartUsers({
chartData,
report,
date,
}: ViewChartUsersProps) {
function ChartUsersView({ chartData, report, date }: ChartUsersViewProps) {
const trpc = useTRPC();
// Group series by base event/formula (ignoring breakdowns)
const baseSeries = useMemo(() => {
const grouped = new Map<
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>(
const [selectedSerieId, setSelectedSerieId] = useState<string | null>(
report.series[0]?.id || null,
);
const [selectedBreakdownId, setSelectedBreakdownId] = useState<string | null>(
null,
);
const [selectedBreakdownIndex, setSelectedBreakdownIndex] = useState<
number | null
>(null);
const selectedBaseSerie = useMemo(
() => baseSeries.find((bs) => bs.baseEventId === selectedBaseSerieId),
[baseSeries, selectedBaseSerieId],
const selectedReportSerie = useMemo(
() => report.series.find((s) => s.id === selectedSerieId),
[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(() => {
if (
!selectedBaseSerie ||
selectedBreakdownIndex === null ||
!selectedBaseSerie.breakdownSeries[selectedBreakdownIndex]
) {
return null;
}
return selectedBaseSerie.breakdownSeries[selectedBreakdownIndex];
}, [selectedBaseSerie, selectedBreakdownIndex]);
if (!selectedBreakdownId) return null;
return matchingChartSeries.find((s) => s.id === selectedBreakdownId);
}, [matchingChartSeries, selectedBreakdownId]);
// Reset breakdown selection when base serie changes
const handleBaseSerieChange = (value: string) => {
setSelectedBaseSerieId(value);
setSelectedBreakdownIndex(null);
// Reset breakdown selection when serie changes
const handleSerieChange = (value: string) => {
setSelectedSerieId(value);
setSelectedBreakdownId(null);
};
const selectedSerie = selectedBreakdown || selectedBaseSerie;
const profilesQuery = useQuery(
trpc.chart.getProfiles.queryOptions(
{
projectId: report.projectId,
date: date,
series:
selectedSerie &&
selectedBaseSerie?.reportSerie &&
selectedBaseSerie.reportSerie.type === 'event'
? [selectedBaseSerie.reportSerie]
selectedReportSerie && selectedReportSerie.type === 'event'
? [selectedReportSerie]
: [],
breakdowns: selectedBreakdown?.breakdowns,
breakdowns: selectedBreakdown?.event.breakdowns,
interval: report.interval,
},
{
enabled:
!!selectedSerie &&
!!selectedBaseSerie?.reportSerie &&
selectedBaseSerie.reportSerie.type === 'event',
enabled: !!selectedReportSerie && selectedReportSerie.type === 'event',
},
),
);
@@ -129,118 +209,191 @@ export default function ViewChartUsers({
const profiles = profilesQuery.data ?? [];
return (
<ModalContent>
<ModalHeader title="View Users" />
<p className="text-sm text-muted-foreground mb-4">
Users who performed actions on {new Date(date).toLocaleDateString()}
</p>
<div className="flex flex-col gap-4">
{baseSeries.length > 0 && (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<label className="text-sm font-medium">Serie:</label>
<ScrollableModal
header={
<div>
<ModalHeader
title="View Users"
text={`Users who performed actions on ${new Date(date).toLocaleDateString()}`}
/>
{report.series.length > 0 && (
<div className="col md:row gap-2">
<Select
value={selectedBaseSerieId || ''}
onValueChange={handleBaseSerieChange}
value={selectedSerieId || ''}
onValueChange={handleSerieChange}
>
<SelectTrigger className="w-[200px]">
<SelectTrigger className="flex-1">
<SelectValue placeholder="Select Serie" />
</SelectTrigger>
<SelectContent>
{baseSeries.map((baseSerie) => (
<SelectItem
key={baseSerie.baseEventId}
value={baseSerie.baseEventId}
>
{baseSerie.baseName}
{report.series.map((serie) => (
<SelectItem key={serie.id} value={serie.id || ''}>
{serie.type === 'event'
? serie.displayName || serie.name
: serie.displayName || 'Formula'}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{selectedBaseSerie &&
selectedBaseSerie.breakdownSeries.length > 1 && (
<div className="flex items-center gap-2">
<label className="text-sm font-medium">Breakdown:</label>
{matchingChartSeries.length > 1 && (
<Select
value={selectedBreakdownIndex?.toString() || ''}
onValueChange={(value) =>
setSelectedBreakdownIndex(
value ? Number.parseInt(value, 10) : null,
)
}
value={selectedBreakdownId || ''}
onValueChange={(value) => setSelectedBreakdownId(value)}
>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="All Breakdowns" />
<SelectTrigger className="flex-1">
<SelectValue placeholder="Select Breakdown" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">All Breakdowns</SelectItem>
{selectedBaseSerie.breakdownSeries.map((bdSerie, idx) => (
<SelectItem
key={bdSerie.serie.id}
value={idx.toString()}
>
{bdSerie.serie.names.slice(1).join(' > ') ||
'No Breakdown'}
{matchingChartSeries
.sort((a, b) => b.metrics.sum - a.metrics.sum)
.map((serie) => (
<SelectItem key={serie.id} value={serie.id}>
{Object.values(serie.event.breakdowns ?? {}).join(
', ',
)}
<DropdownMenuShortcut className="ml-auto">
({serie.data.find((d) => d.date === date)?.count})
</DropdownMenuShortcut>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
)}
</div>
}
>
<div className="col">
{profilesQuery.isLoading ? (
<div className="flex items-center justify-center py-8">
<div className="text-muted-foreground">Loading users...</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">
<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>
<ProfileList profiles={profiles} />
)}
</div>
</div>
))}
</div>
</div>
)}
<ButtonContainer>
<Button type="button" variant="outline" onClick={() => popModal()}>
Close
</Button>
</ButtonContainer>
</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

@@ -340,3 +340,37 @@ button {
.animate-ping-slow {
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'>,
timezone: string,
) {
const ranges = getDatesFromRange(range, timezone);
if (startDate && endDate) {
return { startDate: startDate, endDate: endDate };
}
const ranges = getDatesFromRange(range, timezone);
if (!startDate && endDate) {
return { startDate: ranges.startDate, endDate: endDate };
}

View File

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

View File

@@ -23,6 +23,7 @@ import {
getSettingsForProject,
} from '@openpanel/db';
import {
type IChartEvent,
zChartEvent,
zChartEventFilter,
zChartInput,
@@ -621,6 +622,122 @@ export const chartRouter = createTRPCRouter({
const ids = profileIds.map((p) => p.profile_id).filter(Boolean);
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;
}),
});