wip
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
44
apps/start/src/modals/Modal/scrollable-modal.tsx
Normal file
44
apps/start/src/modals/Modal/scrollable-modal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user