wip
This commit is contained in:
@@ -7,6 +7,7 @@ import { cn } from '@/utils/cn';
|
|||||||
import { getChartColor } from '@/utils/theme';
|
import { getChartColor } from '@/utils/theme';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { isSameDay, isSameHour, isSameMonth, isSameWeek } from 'date-fns';
|
import { isSameDay, isSameHour, isSameMonth, isSameWeek } from 'date-fns';
|
||||||
|
import { BookmarkIcon, UsersIcon } from 'lucide-react';
|
||||||
import { last } from 'ramda';
|
import { last } from 'ramda';
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
@@ -25,6 +26,10 @@ import {
|
|||||||
|
|
||||||
import { useDashedStroke } from '@/hooks/use-dashed-stroke';
|
import { useDashedStroke } from '@/hooks/use-dashed-stroke';
|
||||||
import { useXAxisProps, useYAxisProps } from '../common/axis';
|
import { useXAxisProps, useYAxisProps } from '../common/axis';
|
||||||
|
import {
|
||||||
|
ChartClickMenu,
|
||||||
|
type ChartClickMenuItem,
|
||||||
|
} from '../common/chart-click-menu';
|
||||||
import { ReportChartTooltip } from '../common/report-chart-tooltip';
|
import { ReportChartTooltip } from '../common/report-chart-tooltip';
|
||||||
import { ReportTable } from '../common/report-table';
|
import { ReportTable } from '../common/report-table';
|
||||||
import { SerieIcon } from '../common/serie-icon';
|
import { SerieIcon } from '../common/serie-icon';
|
||||||
@@ -45,6 +50,8 @@ export function Chart({ data }: Props) {
|
|||||||
endDate,
|
endDate,
|
||||||
range,
|
range,
|
||||||
lineType,
|
lineType,
|
||||||
|
series: reportSeries,
|
||||||
|
breakdowns,
|
||||||
},
|
},
|
||||||
isEditMode,
|
isEditMode,
|
||||||
options: { hideXAxis, hideYAxis },
|
options: { hideXAxis, hideYAxis },
|
||||||
@@ -126,16 +133,66 @@ export function Chart({ data }: Props) {
|
|||||||
interval,
|
interval,
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleChartClick = useCallback((e: any) => {
|
const getMenuItems = useCallback(
|
||||||
if (e?.activePayload?.[0]) {
|
(e: any, clickedData: any): ChartClickMenuItem[] => {
|
||||||
const clickedData = e.activePayload[0].payload;
|
const items: ChartClickMenuItem[] = [];
|
||||||
if (clickedData.date) {
|
|
||||||
pushModal('AddReference', {
|
if (!clickedData?.date) {
|
||||||
datetime: new Date(clickedData.date).toISOString(),
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
// View Users - only show if we have projectId
|
||||||
|
if (projectId) {
|
||||||
|
items.push({
|
||||||
|
label: 'View Users',
|
||||||
|
icon: <UsersIcon size={16} />,
|
||||||
|
onClick: () => {
|
||||||
|
pushModal('ViewChartUsers', {
|
||||||
|
type: 'chart',
|
||||||
|
chartData: data,
|
||||||
|
report: {
|
||||||
|
projectId,
|
||||||
|
series: reportSeries,
|
||||||
|
breakdowns: breakdowns || [],
|
||||||
|
interval,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
range,
|
||||||
|
previous,
|
||||||
|
chartType: 'area',
|
||||||
|
metric: 'sum',
|
||||||
|
},
|
||||||
|
date: clickedData.date,
|
||||||
|
});
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}, []);
|
// Add Reference - always show
|
||||||
|
items.push({
|
||||||
|
label: 'Add Reference',
|
||||||
|
icon: <BookmarkIcon size={16} />,
|
||||||
|
onClick: () => {
|
||||||
|
pushModal('AddReference', {
|
||||||
|
datetime: new Date(clickedData.date).toISOString(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return items;
|
||||||
|
},
|
||||||
|
[
|
||||||
|
projectId,
|
||||||
|
data,
|
||||||
|
reportSeries,
|
||||||
|
breakdowns,
|
||||||
|
interval,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
range,
|
||||||
|
previous,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
const { getStrokeDasharray, calcStrokeDasharray, handleAnimationEnd } =
|
const { getStrokeDasharray, calcStrokeDasharray, handleAnimationEnd } =
|
||||||
useDashedStroke({
|
useDashedStroke({
|
||||||
@@ -144,9 +201,10 @@ export function Chart({ data }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ReportChartTooltip.TooltipProvider references={references.data}>
|
<ReportChartTooltip.TooltipProvider references={references.data}>
|
||||||
<div className={cn('h-full w-full', isEditMode && 'card p-4')}>
|
<ChartClickMenu getMenuItems={getMenuItems}>
|
||||||
<ResponsiveContainer>
|
<div className={cn('h-full w-full', isEditMode && 'card p-4')}>
|
||||||
<ComposedChart data={rechartData} onClick={handleChartClick}>
|
<ResponsiveContainer>
|
||||||
|
<ComposedChart data={rechartData}>
|
||||||
<Customized component={calcStrokeDasharray} />
|
<Customized component={calcStrokeDasharray} />
|
||||||
<Line
|
<Line
|
||||||
dataKey="calcStrokeDasharray"
|
dataKey="calcStrokeDasharray"
|
||||||
@@ -244,6 +302,7 @@ export function Chart({ data }: Props) {
|
|||||||
setVisibleSeries={setVisibleSeries}
|
setVisibleSeries={setVisibleSeries}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
</ChartClickMenu>
|
||||||
</ReportChartTooltip.TooltipProvider>
|
</ReportChartTooltip.TooltipProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { ColorSquare } from '@/components/color-square';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { pushModal } from '@/modals';
|
||||||
import type { RouterOutputs } from '@/trpc/client';
|
import type { RouterOutputs } from '@/trpc/client';
|
||||||
import { cn } from '@/utils/cn';
|
import { cn } from '@/utils/cn';
|
||||||
import { ChevronRightIcon, InfoIcon } from 'lucide-react';
|
import { ChevronRightIcon, InfoIcon, UsersIcon } from 'lucide-react';
|
||||||
|
|
||||||
import { alphabetIds } from '@openpanel/constants';
|
import { alphabetIds } from '@openpanel/constants';
|
||||||
|
|
||||||
@@ -23,6 +25,7 @@ import {
|
|||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
import { useXAxisProps, useYAxisProps } from '../common/axis';
|
import { useXAxisProps, useYAxisProps } from '../common/axis';
|
||||||
import { PreviousDiffIndicatorPure } from '../common/previous-diff-indicator';
|
import { PreviousDiffIndicatorPure } from '../common/previous-diff-indicator';
|
||||||
|
import { useReportChartContext } from '../context';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
data: {
|
data: {
|
||||||
@@ -113,11 +116,50 @@ function ChartName({
|
|||||||
export function Tables({
|
export function Tables({
|
||||||
data: {
|
data: {
|
||||||
current: { steps, mostDropoffsStep, lastStep, breakdowns },
|
current: { steps, mostDropoffsStep, lastStep, breakdowns },
|
||||||
previous,
|
previous: previousData,
|
||||||
},
|
},
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const number = useNumber();
|
const number = useNumber();
|
||||||
const hasHeader = breakdowns.length > 0;
|
const hasHeader = breakdowns.length > 0;
|
||||||
|
const {
|
||||||
|
report: {
|
||||||
|
projectId,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
range,
|
||||||
|
interval,
|
||||||
|
series: reportSeries,
|
||||||
|
breakdowns: reportBreakdowns,
|
||||||
|
previous,
|
||||||
|
funnelWindow,
|
||||||
|
funnelGroup,
|
||||||
|
},
|
||||||
|
} = useReportChartContext();
|
||||||
|
|
||||||
|
const handleInspectStep = (step: (typeof steps)[0], stepIndex: number) => {
|
||||||
|
if (!projectId || !step.event.id) return;
|
||||||
|
|
||||||
|
// For funnels, we need to pass the step index so the modal can query
|
||||||
|
// users who completed at least that step in the funnel sequence
|
||||||
|
pushModal('ViewChartUsers', {
|
||||||
|
type: 'funnel',
|
||||||
|
report: {
|
||||||
|
projectId,
|
||||||
|
series: reportSeries,
|
||||||
|
breakdowns: reportBreakdowns || [],
|
||||||
|
interval: interval || 'day',
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
range,
|
||||||
|
previous,
|
||||||
|
chartType: 'funnel',
|
||||||
|
metric: 'sum',
|
||||||
|
funnelWindow,
|
||||||
|
funnelGroup,
|
||||||
|
},
|
||||||
|
stepIndex, // Pass the step index for funnel queries
|
||||||
|
});
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<div className={cn('col @container divide-y divide-border card')}>
|
<div className={cn('col @container divide-y divide-border card')}>
|
||||||
{hasHeader && <ChartName breakdowns={breakdowns} className="p-4 py-3" />}
|
{hasHeader && <ChartName breakdowns={breakdowns} className="p-4 py-3" />}
|
||||||
@@ -128,11 +170,11 @@ export function Tables({
|
|||||||
label="Conversion"
|
label="Conversion"
|
||||||
value={number.formatWithUnit(lastStep?.percent / 100, '%')}
|
value={number.formatWithUnit(lastStep?.percent / 100, '%')}
|
||||||
enhancer={
|
enhancer={
|
||||||
previous && (
|
previousData && (
|
||||||
<PreviousDiffIndicatorPure
|
<PreviousDiffIndicatorPure
|
||||||
{...getPreviousMetric(
|
{...getPreviousMetric(
|
||||||
lastStep?.percent,
|
lastStep?.percent,
|
||||||
previous.lastStep?.percent,
|
previousData.lastStep?.percent,
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -143,11 +185,11 @@ export function Tables({
|
|||||||
label="Completed"
|
label="Completed"
|
||||||
value={number.format(lastStep?.count)}
|
value={number.format(lastStep?.count)}
|
||||||
enhancer={
|
enhancer={
|
||||||
previous && (
|
previousData && (
|
||||||
<PreviousDiffIndicatorPure
|
<PreviousDiffIndicatorPure
|
||||||
{...getPreviousMetric(
|
{...getPreviousMetric(
|
||||||
lastStep?.count,
|
lastStep?.count,
|
||||||
previous.lastStep?.count,
|
previousData.lastStep?.count,
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -238,6 +280,28 @@ export function Tables({
|
|||||||
className: 'text-right font-mono font-semibold',
|
className: 'text-right font-mono font-semibold',
|
||||||
width: '90px',
|
width: '90px',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: '',
|
||||||
|
render: (item) => (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const stepIndex = steps.findIndex(
|
||||||
|
(s) => s.event.id === item.event.id,
|
||||||
|
);
|
||||||
|
handleInspectStep(item, stepIndex);
|
||||||
|
}}
|
||||||
|
title="View users who completed this step"
|
||||||
|
>
|
||||||
|
<UsersIcon size={16} />
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
className: 'text-right',
|
||||||
|
width: '48px',
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type { IChartData } from '@/trpc/client';
|
|||||||
import { cn } from '@/utils/cn';
|
import { cn } from '@/utils/cn';
|
||||||
import { getChartColor } from '@/utils/theme';
|
import { getChartColor } from '@/utils/theme';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { BookmarkIcon, UsersIcon } from 'lucide-react';
|
||||||
import React, { useCallback } from 'react';
|
import React, { useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
Bar,
|
Bar,
|
||||||
@@ -20,6 +21,10 @@ import {
|
|||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
|
|
||||||
import { useXAxisProps, useYAxisProps } from '../common/axis';
|
import { useXAxisProps, useYAxisProps } from '../common/axis';
|
||||||
|
import {
|
||||||
|
ChartClickMenu,
|
||||||
|
type ChartClickMenuItem,
|
||||||
|
} from '../common/chart-click-menu';
|
||||||
import { ReportChartTooltip } from '../common/report-chart-tooltip';
|
import { ReportChartTooltip } from '../common/report-chart-tooltip';
|
||||||
import { ReportTable } from '../common/report-table';
|
import { ReportTable } from '../common/report-table';
|
||||||
import { useReportChartContext } from '../context';
|
import { useReportChartContext } from '../context';
|
||||||
@@ -47,7 +52,16 @@ function BarHover({ x, y, width, height, top, left, right, bottom }: any) {
|
|||||||
export function Chart({ data }: Props) {
|
export function Chart({ data }: Props) {
|
||||||
const {
|
const {
|
||||||
isEditMode,
|
isEditMode,
|
||||||
report: { previous, interval, projectId, startDate, endDate, range },
|
report: {
|
||||||
|
previous,
|
||||||
|
interval,
|
||||||
|
projectId,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
range,
|
||||||
|
series: reportSeries,
|
||||||
|
breakdowns,
|
||||||
|
},
|
||||||
options: { hideXAxis, hideYAxis },
|
options: { hideXAxis, hideYAxis },
|
||||||
} = useReportChartContext();
|
} = useReportChartContext();
|
||||||
const trpc = useTRPC();
|
const trpc = useTRPC();
|
||||||
@@ -74,22 +88,73 @@ export function Chart({ data }: Props) {
|
|||||||
interval,
|
interval,
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleChartClick = useCallback((e: any) => {
|
const getMenuItems = useCallback(
|
||||||
if (e?.activePayload?.[0]) {
|
(e: any, clickedData: any): ChartClickMenuItem[] => {
|
||||||
const clickedData = e.activePayload[0].payload;
|
const items: ChartClickMenuItem[] = [];
|
||||||
if (clickedData.date) {
|
|
||||||
pushModal('AddReference', {
|
if (!clickedData?.date) {
|
||||||
datetime: new Date(clickedData.date).toISOString(),
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
// View Users - only show if we have projectId
|
||||||
|
if (projectId) {
|
||||||
|
items.push({
|
||||||
|
label: 'View Users',
|
||||||
|
icon: <UsersIcon size={16} />,
|
||||||
|
onClick: () => {
|
||||||
|
pushModal('ViewChartUsers', {
|
||||||
|
type: 'chart',
|
||||||
|
chartData: data,
|
||||||
|
report: {
|
||||||
|
projectId,
|
||||||
|
series: reportSeries,
|
||||||
|
breakdowns: breakdowns || [],
|
||||||
|
interval,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
range,
|
||||||
|
previous,
|
||||||
|
chartType: 'histogram',
|
||||||
|
metric: 'sum',
|
||||||
|
},
|
||||||
|
date: clickedData.date,
|
||||||
|
});
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}, []);
|
// Add Reference - always show
|
||||||
|
items.push({
|
||||||
|
label: 'Add Reference',
|
||||||
|
icon: <BookmarkIcon size={16} />,
|
||||||
|
onClick: () => {
|
||||||
|
pushModal('AddReference', {
|
||||||
|
datetime: new Date(clickedData.date).toISOString(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return items;
|
||||||
|
},
|
||||||
|
[
|
||||||
|
projectId,
|
||||||
|
data,
|
||||||
|
reportSeries,
|
||||||
|
breakdowns,
|
||||||
|
interval,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
range,
|
||||||
|
previous,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ReportChartTooltip.TooltipProvider references={references.data}>
|
<ReportChartTooltip.TooltipProvider references={references.data}>
|
||||||
<div className={cn('h-full w-full', isEditMode && 'card p-4')}>
|
<ChartClickMenu getMenuItems={getMenuItems}>
|
||||||
<ResponsiveContainer>
|
<div className={cn('h-full w-full', isEditMode && 'card p-4')}>
|
||||||
<BarChart data={rechartData} onClick={handleChartClick}>
|
<ResponsiveContainer>
|
||||||
|
<BarChart data={rechartData}>
|
||||||
<CartesianGrid
|
<CartesianGrid
|
||||||
strokeDasharray="3 3"
|
strokeDasharray="3 3"
|
||||||
vertical={false}
|
vertical={false}
|
||||||
@@ -152,6 +217,7 @@ export function Chart({ data }: Props) {
|
|||||||
setVisibleSeries={setVisibleSeries}
|
setVisibleSeries={setVisibleSeries}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
</ChartClickMenu>
|
||||||
</ReportChartTooltip.TooltipProvider>
|
</ReportChartTooltip.TooltipProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,3 @@
|
|||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from '@/components/ui/dropdown-menu';
|
|
||||||
import { useRechartDataModel } from '@/hooks/use-rechart-data-model';
|
import { useRechartDataModel } from '@/hooks/use-rechart-data-model';
|
||||||
import { useVisibleSeries } from '@/hooks/use-visible-series';
|
import { useVisibleSeries } from '@/hooks/use-visible-series';
|
||||||
import { useTRPC } from '@/integrations/trpc/react';
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
@@ -11,12 +5,11 @@ import { pushModal } from '@/modals';
|
|||||||
import type { IChartData } from '@/trpc/client';
|
import type { IChartData } from '@/trpc/client';
|
||||||
import { cn } from '@/utils/cn';
|
import { cn } from '@/utils/cn';
|
||||||
import { getChartColor } from '@/utils/theme';
|
import { getChartColor } from '@/utils/theme';
|
||||||
import type { IChartEvent } from '@openpanel/validation';
|
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { isSameDay, isSameHour, isSameMonth, isSameWeek } from 'date-fns';
|
import { isSameDay, isSameHour, isSameMonth, isSameWeek } from 'date-fns';
|
||||||
import { BookmarkIcon, UsersIcon } from 'lucide-react';
|
import { BookmarkIcon, UsersIcon } from 'lucide-react';
|
||||||
import { last } from 'ramda';
|
import { last } from 'ramda';
|
||||||
import { useCallback, useState } from 'react';
|
import { useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
CartesianGrid,
|
CartesianGrid,
|
||||||
ComposedChart,
|
ComposedChart,
|
||||||
@@ -32,6 +25,10 @@ import {
|
|||||||
|
|
||||||
import { useDashedStroke } from '@/hooks/use-dashed-stroke';
|
import { useDashedStroke } from '@/hooks/use-dashed-stroke';
|
||||||
import { useXAxisProps, useYAxisProps } from '../common/axis';
|
import { useXAxisProps, useYAxisProps } from '../common/axis';
|
||||||
|
import {
|
||||||
|
ChartClickMenu,
|
||||||
|
type ChartClickMenuItem,
|
||||||
|
} from '../common/chart-click-menu';
|
||||||
import { ReportChartTooltip } from '../common/report-chart-tooltip';
|
import { ReportChartTooltip } from '../common/report-chart-tooltip';
|
||||||
import { ReportTable } from '../common/report-table';
|
import { ReportTable } from '../common/report-table';
|
||||||
import { SerieIcon } from '../common/serie-icon';
|
import { SerieIcon } from '../common/serie-icon';
|
||||||
@@ -58,14 +55,6 @@ export function Chart({ data }: Props) {
|
|||||||
isEditMode,
|
isEditMode,
|
||||||
options: { hideXAxis, hideYAxis, maxDomain },
|
options: { hideXAxis, hideYAxis, maxDomain },
|
||||||
} = useReportChartContext();
|
} = useReportChartContext();
|
||||||
const [clickPosition, setClickPosition] = useState<{
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
} | null>(null);
|
|
||||||
const [clickedData, setClickedData] = useState<{
|
|
||||||
date: string;
|
|
||||||
serieId?: string;
|
|
||||||
} | null>(null);
|
|
||||||
const dataLength = data.series[0]?.data?.length || 0;
|
const dataLength = data.series[0]?.data?.length || 0;
|
||||||
const trpc = useTRPC();
|
const trpc = useTRPC();
|
||||||
const references = useQuery(
|
const references = useQuery(
|
||||||
@@ -146,171 +135,146 @@ export function Chart({ data }: Props) {
|
|||||||
hide: hideYAxis,
|
hide: hideYAxis,
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleChartClick = useCallback((e: any) => {
|
const getMenuItems = useCallback(
|
||||||
if (e?.activePayload?.[0]) {
|
(e: any, clickedData: any): ChartClickMenuItem[] => {
|
||||||
const payload = e.activePayload[0].payload;
|
const items: ChartClickMenuItem[] = [];
|
||||||
const activeCoordinate = e.activeCoordinate;
|
|
||||||
if (payload.date) {
|
|
||||||
// Find the first valid serie ID from activePayload (skip calcStrokeDasharray)
|
|
||||||
const validPayload = e.activePayload.find(
|
|
||||||
(p: any) =>
|
|
||||||
p.dataKey &&
|
|
||||||
p.dataKey !== 'calcStrokeDasharray' &&
|
|
||||||
typeof p.dataKey === 'string' &&
|
|
||||||
p.dataKey.includes(':count'),
|
|
||||||
);
|
|
||||||
const serieId = validPayload?.dataKey?.toString().replace(':count', '');
|
|
||||||
|
|
||||||
setClickedData({
|
if (!clickedData?.date) {
|
||||||
date: payload.date,
|
return items;
|
||||||
serieId,
|
}
|
||||||
});
|
|
||||||
setClickPosition({
|
// Extract serie ID from the click event if needed
|
||||||
x: activeCoordinate?.x ?? 0,
|
// activePayload is an array of payload objects
|
||||||
y: activeCoordinate?.y ?? 0,
|
const validPayload = e.activePayload?.find(
|
||||||
|
(p: any) =>
|
||||||
|
p.dataKey &&
|
||||||
|
p.dataKey !== 'calcStrokeDasharray' &&
|
||||||
|
typeof p.dataKey === 'string' &&
|
||||||
|
p.dataKey.includes(':count'),
|
||||||
|
);
|
||||||
|
const serieId = validPayload?.dataKey?.toString().replace(':count', '');
|
||||||
|
|
||||||
|
// View Users - only show if we have projectId
|
||||||
|
if (projectId) {
|
||||||
|
items.push({
|
||||||
|
label: 'View Users',
|
||||||
|
icon: <UsersIcon size={16} />,
|
||||||
|
onClick: () => {
|
||||||
|
pushModal('ViewChartUsers', {
|
||||||
|
type: 'chart',
|
||||||
|
chartData: data,
|
||||||
|
report: {
|
||||||
|
projectId,
|
||||||
|
series: reportSeries,
|
||||||
|
breakdowns: breakdowns || [],
|
||||||
|
interval,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
range,
|
||||||
|
previous,
|
||||||
|
chartType: 'linear',
|
||||||
|
metric: 'sum',
|
||||||
|
},
|
||||||
|
date: clickedData.date,
|
||||||
|
});
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleViewUsers = useCallback(() => {
|
// Add Reference - always show
|
||||||
if (!clickedData || !projectId) return;
|
items.push({
|
||||||
|
label: 'Add Reference',
|
||||||
|
icon: <BookmarkIcon size={16} />,
|
||||||
|
onClick: () => {
|
||||||
|
pushModal('AddReference', {
|
||||||
|
datetime: new Date(clickedData.date).toISOString(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Pass the chart data (which we already have) and the report config
|
return items;
|
||||||
pushModal('ViewChartUsers', {
|
},
|
||||||
chartData: data,
|
[
|
||||||
report: {
|
projectId,
|
||||||
projectId,
|
data,
|
||||||
series: reportSeries,
|
reportSeries,
|
||||||
breakdowns: breakdowns || [],
|
breakdowns,
|
||||||
interval,
|
interval,
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
range,
|
range,
|
||||||
previous,
|
previous,
|
||||||
chartType: 'linear',
|
],
|
||||||
metric: 'sum',
|
);
|
||||||
},
|
|
||||||
date: clickedData.date,
|
|
||||||
});
|
|
||||||
setClickPosition(null);
|
|
||||||
setClickedData(null);
|
|
||||||
}, [
|
|
||||||
clickedData,
|
|
||||||
projectId,
|
|
||||||
data,
|
|
||||||
reportSeries,
|
|
||||||
breakdowns,
|
|
||||||
interval,
|
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
range,
|
|
||||||
previous,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const handleAddReference = useCallback(() => {
|
|
||||||
if (!clickedData) return;
|
|
||||||
pushModal('AddReference', {
|
|
||||||
datetime: new Date(clickedData.date).toISOString(),
|
|
||||||
});
|
|
||||||
setClickPosition(null);
|
|
||||||
setClickedData(null);
|
|
||||||
}, [clickedData]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ReportChartTooltip.TooltipProvider references={references.data}>
|
<ReportChartTooltip.TooltipProvider references={references.data}>
|
||||||
<div className={cn('h-full w-full', isEditMode && 'card p-4')}>
|
<ChartClickMenu getMenuItems={getMenuItems}>
|
||||||
<DropdownMenu
|
<div className={cn('h-full w-full', isEditMode && 'card p-4')}>
|
||||||
open={clickPosition !== null}
|
<ResponsiveContainer>
|
||||||
onOpenChange={(open) => {
|
<ComposedChart data={rechartData}>
|
||||||
if (!open) {
|
<Customized component={calcStrokeDasharray} />
|
||||||
setClickPosition(null);
|
<Line
|
||||||
setClickedData(null);
|
dataKey="calcStrokeDasharray"
|
||||||
}
|
legendType="none"
|
||||||
}}
|
animationDuration={0}
|
||||||
>
|
onAnimationEnd={handleAnimationEnd}
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
left: clickPosition?.x ?? -9999,
|
|
||||||
top: clickPosition?.y ?? -9999,
|
|
||||||
pointerEvents: 'none',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent>
|
|
||||||
<DropdownMenuItem onClick={handleViewUsers}>
|
|
||||||
<UsersIcon size={16} className="mr-2" />
|
|
||||||
View Users
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={handleAddReference}>
|
|
||||||
<BookmarkIcon size={16} className="mr-2" />
|
|
||||||
Add Reference
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
<ResponsiveContainer>
|
|
||||||
<ComposedChart data={rechartData} onClick={handleChartClick}>
|
|
||||||
<Customized component={calcStrokeDasharray} />
|
|
||||||
<Line
|
|
||||||
dataKey="calcStrokeDasharray"
|
|
||||||
legendType="none"
|
|
||||||
animationDuration={0}
|
|
||||||
onAnimationEnd={handleAnimationEnd}
|
|
||||||
/>
|
|
||||||
<CartesianGrid
|
|
||||||
strokeDasharray="3 3"
|
|
||||||
horizontal={true}
|
|
||||||
vertical={false}
|
|
||||||
className="stroke-border"
|
|
||||||
/>
|
|
||||||
{references.data?.map((ref) => (
|
|
||||||
<ReferenceLine
|
|
||||||
key={ref.id}
|
|
||||||
x={ref.date.getTime()}
|
|
||||||
stroke={'oklch(from var(--foreground) l c h / 0.1)'}
|
|
||||||
strokeDasharray={'3 3'}
|
|
||||||
label={{
|
|
||||||
value: ref.title,
|
|
||||||
position: 'centerTop',
|
|
||||||
fill: '#334155',
|
|
||||||
fontSize: 12,
|
|
||||||
}}
|
|
||||||
fontSize={10}
|
|
||||||
/>
|
/>
|
||||||
))}
|
<CartesianGrid
|
||||||
<YAxis
|
strokeDasharray="3 3"
|
||||||
{...yAxisProps}
|
horizontal={true}
|
||||||
domain={maxDomain ? [0, maxDomain] : undefined}
|
vertical={false}
|
||||||
/>
|
className="stroke-border"
|
||||||
<XAxis {...xAxisProps} />
|
/>
|
||||||
{series.length > 1 && <Legend content={<CustomLegend />} />}
|
{references.data?.map((ref) => (
|
||||||
<Tooltip content={<ReportChartTooltip.Tooltip />} />
|
<ReferenceLine
|
||||||
{/* {series.map((serie) => {
|
key={ref.id}
|
||||||
const color = getChartColor(serie.index);
|
x={ref.date.getTime()}
|
||||||
return (
|
stroke={'oklch(from var(--foreground) l c h / 0.1)'}
|
||||||
<React.Fragment key={serie.id}>
|
strokeDasharray={'3 3'}
|
||||||
<defs>
|
label={{
|
||||||
{isAreaStyle && (
|
value: ref.title,
|
||||||
<linearGradient
|
position: 'centerTop',
|
||||||
id={`color${color}`}
|
fill: '#334155',
|
||||||
x1="0"
|
fontSize: 12,
|
||||||
y1="0"
|
}}
|
||||||
x2="0"
|
fontSize={10}
|
||||||
y2="1"
|
/>
|
||||||
>
|
))}
|
||||||
<stop offset="0%" stopColor={color} stopOpacity={0.8} />
|
<YAxis
|
||||||
<stop
|
{...yAxisProps}
|
||||||
offset="100%"
|
domain={maxDomain ? [0, maxDomain] : undefined}
|
||||||
stopColor={color}
|
/>
|
||||||
stopOpacity={0.1}
|
<XAxis {...xAxisProps} />
|
||||||
/>
|
{series.length > 1 && <Legend content={<CustomLegend />} />}
|
||||||
</linearGradient>
|
<Tooltip content={<ReportChartTooltip.Tooltip />} />
|
||||||
)}
|
|
||||||
</defs>
|
<defs>
|
||||||
|
<filter
|
||||||
|
id="rainbow-line-glow"
|
||||||
|
x="-20%"
|
||||||
|
y="-20%"
|
||||||
|
width="140%"
|
||||||
|
height="140%"
|
||||||
|
>
|
||||||
|
<feGaussianBlur stdDeviation="5" result="blur" />
|
||||||
|
<feComponentTransfer in="blur" result="dimmedBlur">
|
||||||
|
<feFuncA type="linear" slope="0.5" />
|
||||||
|
</feComponentTransfer>
|
||||||
|
<feComposite
|
||||||
|
in="SourceGraphic"
|
||||||
|
in2="dimmedBlur"
|
||||||
|
operator="over"
|
||||||
|
/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
{series.map((serie) => {
|
||||||
|
const color = getChartColor(serie.index);
|
||||||
|
return (
|
||||||
<Line
|
<Line
|
||||||
dot={isAreaStyle && dataLength <= 8}
|
key={serie.id}
|
||||||
|
dot={dataLength <= 8}
|
||||||
type={lineType}
|
type={lineType}
|
||||||
name={serie.id}
|
name={serie.id}
|
||||||
isAnimationActive={false}
|
isAnimationActive={false}
|
||||||
@@ -324,100 +288,46 @@ export function Chart({ data }: Props) {
|
|||||||
}
|
}
|
||||||
// Use for legend
|
// Use for legend
|
||||||
fill={color}
|
fill={color}
|
||||||
|
filter={
|
||||||
|
series.length === 1
|
||||||
|
? 'url(#rainbow-line-glow)'
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
{previous && (
|
);
|
||||||
<Line
|
})}
|
||||||
type={lineType}
|
|
||||||
name={`${serie.id}:prev`}
|
|
||||||
isAnimationActive
|
|
||||||
dot={false}
|
|
||||||
strokeOpacity={0.3}
|
|
||||||
dataKey={`${serie.id}:prev:count`}
|
|
||||||
stroke={color}
|
|
||||||
// Use for legend
|
|
||||||
fill={color}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</React.Fragment>
|
|
||||||
);
|
|
||||||
})} */}
|
|
||||||
|
|
||||||
<defs>
|
{/* Previous */}
|
||||||
<filter
|
{previous
|
||||||
id="rainbow-line-glow"
|
? series.map((serie) => {
|
||||||
x="-20%"
|
const color = getChartColor(serie.index);
|
||||||
y="-20%"
|
return (
|
||||||
width="140%"
|
<Line
|
||||||
height="140%"
|
key={`${serie.id}:prev`}
|
||||||
>
|
type={lineType}
|
||||||
<feGaussianBlur stdDeviation="5" result="blur" />
|
name={`${serie.id}:prev`}
|
||||||
<feComponentTransfer in="blur" result="dimmedBlur">
|
isAnimationActive
|
||||||
<feFuncA type="linear" slope="0.5" />
|
dot={false}
|
||||||
</feComponentTransfer>
|
strokeOpacity={0.3}
|
||||||
<feComposite
|
dataKey={`${serie.id}:prev:count`}
|
||||||
in="SourceGraphic"
|
stroke={color}
|
||||||
in2="dimmedBlur"
|
// Use for legend
|
||||||
operator="over"
|
fill={color}
|
||||||
/>
|
/>
|
||||||
</filter>
|
);
|
||||||
</defs>
|
})
|
||||||
|
: null}
|
||||||
{series.map((serie) => {
|
</ComposedChart>
|
||||||
const color = getChartColor(serie.index);
|
</ResponsiveContainer>
|
||||||
return (
|
</div>
|
||||||
<Line
|
{isEditMode && (
|
||||||
key={serie.id}
|
<ReportTable
|
||||||
dot={dataLength <= 8}
|
data={data}
|
||||||
type={lineType}
|
visibleSeries={series}
|
||||||
name={serie.id}
|
setVisibleSeries={setVisibleSeries}
|
||||||
isAnimationActive={false}
|
/>
|
||||||
strokeWidth={2}
|
)}
|
||||||
dataKey={`${serie.id}:count`}
|
</ChartClickMenu>
|
||||||
stroke={color}
|
|
||||||
strokeDasharray={
|
|
||||||
useDashedLastLine
|
|
||||||
? getStrokeDasharray(`${serie.id}:count`)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
// Use for legend
|
|
||||||
fill={color}
|
|
||||||
filter={
|
|
||||||
series.length === 1 ? 'url(#rainbow-line-glow)' : undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* Previous */}
|
|
||||||
{previous
|
|
||||||
? series.map((serie) => {
|
|
||||||
const color = getChartColor(serie.index);
|
|
||||||
return (
|
|
||||||
<Line
|
|
||||||
key={`${serie.id}:prev`}
|
|
||||||
type={lineType}
|
|
||||||
name={`${serie.id}:prev`}
|
|
||||||
isAnimationActive
|
|
||||||
dot={false}
|
|
||||||
strokeOpacity={0.3}
|
|
||||||
dataKey={`${serie.id}:prev:count`}
|
|
||||||
stroke={color}
|
|
||||||
// Use for legend
|
|
||||||
fill={color}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
: null}
|
|
||||||
</ComposedChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
{isEditMode && (
|
|
||||||
<ReportTable
|
|
||||||
data={data}
|
|
||||||
visibleSeries={series}
|
|
||||||
setVisibleSeries={setVisibleSeries}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</ReportChartTooltip.TooltipProvider>
|
</ReportChartTooltip.TooltipProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,36 @@
|
|||||||
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
|
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
|
||||||
import type * as React from 'react';
|
import * as React from 'react';
|
||||||
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export const VirtualScrollArea = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
{
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
>(({ children, className }, ref) => {
|
||||||
|
// The ref MUST point directly to the scrollable element
|
||||||
|
// This element MUST have:
|
||||||
|
// 1. overflow-y-auto (or overflow: auto)
|
||||||
|
// 2. A constrained height (via flex-1 min-h-0 or fixed height)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn('overflow-y-auto w-full', className)}
|
||||||
|
style={{
|
||||||
|
// Ensure height is constrained by flex parent
|
||||||
|
height: '100%',
|
||||||
|
maxHeight: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
VirtualScrollArea.displayName = 'VirtualScrollArea';
|
||||||
|
|
||||||
function ScrollArea({
|
function ScrollArea({
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
|
|||||||
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 { ProjectLink } from '@/components/links';
|
||||||
import { Button } from '@/components/ui/button';
|
import { ProfileAvatar } from '@/components/profiles/profile-avatar';
|
||||||
|
import { SerieIcon } from '@/components/report-chart/common/serie-icon';
|
||||||
|
import { DropdownMenuShortcut } from '@/components/ui/dropdown-menu';
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -9,119 +11,197 @@ import {
|
|||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { useTRPC } from '@/integrations/trpc/react';
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
import type { IChartData } from '@/trpc/client';
|
import type { IChartData } from '@/trpc/client';
|
||||||
import type { IChartEvent, IChartInput } from '@openpanel/validation';
|
import { cn } from '@/utils/cn';
|
||||||
|
import { getProfileName } from '@/utils/getters';
|
||||||
|
import type { IChartInput } from '@openpanel/validation';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { UsersIcon } from 'lucide-react';
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
import { useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { popModal } from '.';
|
import { popModal } from '.';
|
||||||
import { ModalContent, ModalHeader } from './Modal/Container';
|
import { ModalHeader } from './Modal/Container';
|
||||||
|
import { ScrollableModal, useScrollableModal } from './Modal/scrollable-modal';
|
||||||
|
|
||||||
interface ViewChartUsersProps {
|
const ProfileItem = ({ profile }: { profile: any }) => {
|
||||||
|
console.log('ProfileItem', profile.id);
|
||||||
|
return (
|
||||||
|
<ProjectLink
|
||||||
|
preload={false}
|
||||||
|
href={`/profiles/${profile.id}`}
|
||||||
|
title={getProfileName(profile, false)}
|
||||||
|
className="col gap-2 rounded-lg border p-2 bg-card"
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.metaKey || e.ctrlKey || e.shiftKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
popModal();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="row gap-2 items-center">
|
||||||
|
<ProfileAvatar {...profile} />
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="font-medium">{getProfileName(profile)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="row gap-4 text-sm overflow-hidden">
|
||||||
|
{profile.properties.country && (
|
||||||
|
<div className="row gap-2 items-center">
|
||||||
|
<SerieIcon name={profile.properties.country} />
|
||||||
|
<span>
|
||||||
|
{profile.properties.country}
|
||||||
|
{profile.properties.city && ` / ${profile.properties.city}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{profile.properties.os && (
|
||||||
|
<div className="row gap-2 items-center">
|
||||||
|
<SerieIcon name={profile.properties.os} />
|
||||||
|
<span>{profile.properties.os}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{profile.properties.browser && (
|
||||||
|
<div className="row gap-2 items-center">
|
||||||
|
<SerieIcon name={profile.properties.browser} />
|
||||||
|
<span>{profile.properties.browser}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ProjectLink>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
// Shared profile list component
|
||||||
|
function ProfileList({ profiles }: { profiles: any[] }) {
|
||||||
|
const ITEM_HEIGHT = 74;
|
||||||
|
const CONTAINER_PADDING = 20;
|
||||||
|
const ITEM_GAP = 5;
|
||||||
|
const { scrollAreaRef } = useScrollableModal();
|
||||||
|
const [isScrollReady, setIsScrollReady] = useState(false);
|
||||||
|
|
||||||
|
// Check if scroll container is ready
|
||||||
|
useEffect(() => {
|
||||||
|
if (scrollAreaRef.current) {
|
||||||
|
setIsScrollReady(true);
|
||||||
|
} else {
|
||||||
|
setIsScrollReady(false);
|
||||||
|
}
|
||||||
|
}, [scrollAreaRef]);
|
||||||
|
|
||||||
|
const virtualizer = useVirtualizer({
|
||||||
|
count: profiles.length,
|
||||||
|
getScrollElement: () => scrollAreaRef.current,
|
||||||
|
estimateSize: () => ITEM_HEIGHT + ITEM_GAP,
|
||||||
|
overscan: 5,
|
||||||
|
paddingStart: CONTAINER_PADDING,
|
||||||
|
paddingEnd: CONTAINER_PADDING,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Re-measure when scroll container becomes available or profiles change
|
||||||
|
useEffect(() => {
|
||||||
|
if (isScrollReady && scrollAreaRef.current) {
|
||||||
|
// Small delay to ensure DOM is ready
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
virtualizer.measure();
|
||||||
|
}, 0);
|
||||||
|
return () => clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
}, [isScrollReady, profiles.length, virtualizer]);
|
||||||
|
|
||||||
|
if (profiles.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<div className="text-muted-foreground">No users found</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const virtualItems = virtualizer.getVirtualItems();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: `${virtualizer.getTotalSize()}px`,
|
||||||
|
width: '100%',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Only the visible items in the virtualizer, manually positioned to be in view */}
|
||||||
|
{virtualItems.map((virtualItem) => {
|
||||||
|
const profile = profiles[virtualItem.index];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={profile.id}
|
||||||
|
data-index={virtualItem.index}
|
||||||
|
ref={virtualizer.measureElement}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: '100%',
|
||||||
|
height: `${virtualItem.size}px`,
|
||||||
|
transform: `translateY(${virtualItem.start}px)`,
|
||||||
|
padding: `0px ${CONTAINER_PADDING}px ${ITEM_GAP}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ProfileItem profile={profile} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chart-specific props and component
|
||||||
|
interface ChartUsersViewProps {
|
||||||
chartData: IChartData;
|
chartData: IChartData;
|
||||||
report: IChartInput;
|
report: IChartInput;
|
||||||
date: string;
|
date: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ViewChartUsers({
|
function ChartUsersView({ chartData, report, date }: ChartUsersViewProps) {
|
||||||
chartData,
|
|
||||||
report,
|
|
||||||
date,
|
|
||||||
}: ViewChartUsersProps) {
|
|
||||||
const trpc = useTRPC();
|
const trpc = useTRPC();
|
||||||
|
const [selectedSerieId, setSelectedSerieId] = useState<string | null>(
|
||||||
// Group series by base event/formula (ignoring breakdowns)
|
report.series[0]?.id || null,
|
||||||
const baseSeries = useMemo(() => {
|
);
|
||||||
const grouped = new Map<
|
const [selectedBreakdownId, setSelectedBreakdownId] = useState<string | null>(
|
||||||
string,
|
|
||||||
{
|
|
||||||
baseName: string;
|
|
||||||
baseEventId: string;
|
|
||||||
reportSerie: IChartInput['series'][0] | undefined;
|
|
||||||
breakdownSeries: Array<{
|
|
||||||
serie: IChartData['series'][0];
|
|
||||||
breakdowns: Record<string, string> | undefined;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
>();
|
|
||||||
|
|
||||||
chartData.series.forEach((serie) => {
|
|
||||||
const baseEventId = serie.event.id || '';
|
|
||||||
const baseName = serie.names[0] || 'Unnamed Serie';
|
|
||||||
|
|
||||||
if (!grouped.has(baseEventId)) {
|
|
||||||
const reportSerie = report.series.find((ss) => ss.id === baseEventId);
|
|
||||||
grouped.set(baseEventId, {
|
|
||||||
baseName,
|
|
||||||
baseEventId,
|
|
||||||
reportSerie,
|
|
||||||
breakdownSeries: [],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const group = grouped.get(baseEventId);
|
|
||||||
if (!group) return;
|
|
||||||
// Extract breakdowns from serie.event.breakdowns (set in format.ts)
|
|
||||||
const breakdowns = (serie.event as any).breakdowns;
|
|
||||||
|
|
||||||
group.breakdownSeries.push({
|
|
||||||
serie,
|
|
||||||
breakdowns,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return Array.from(grouped.values());
|
|
||||||
}, [chartData.series, report.series, report.breakdowns]);
|
|
||||||
|
|
||||||
const [selectedBaseSerieId, setSelectedBaseSerieId] = useState<string | null>(
|
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const [selectedBreakdownIndex, setSelectedBreakdownIndex] = useState<
|
|
||||||
number | null
|
|
||||||
>(null);
|
|
||||||
|
|
||||||
const selectedBaseSerie = useMemo(
|
const selectedReportSerie = useMemo(
|
||||||
() => baseSeries.find((bs) => bs.baseEventId === selectedBaseSerieId),
|
() => report.series.find((s) => s.id === selectedSerieId),
|
||||||
[baseSeries, selectedBaseSerieId],
|
[report.series, selectedSerieId],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Get all chart series that match the selected report serie
|
||||||
|
const matchingChartSeries = useMemo(() => {
|
||||||
|
if (!selectedSerieId || !chartData) return [];
|
||||||
|
return chartData.series.filter((s) => s.event.id === selectedSerieId);
|
||||||
|
}, [chartData?.series, selectedSerieId]);
|
||||||
|
|
||||||
const selectedBreakdown = useMemo(() => {
|
const selectedBreakdown = useMemo(() => {
|
||||||
if (
|
if (!selectedBreakdownId) return null;
|
||||||
!selectedBaseSerie ||
|
return matchingChartSeries.find((s) => s.id === selectedBreakdownId);
|
||||||
selectedBreakdownIndex === null ||
|
}, [matchingChartSeries, selectedBreakdownId]);
|
||||||
!selectedBaseSerie.breakdownSeries[selectedBreakdownIndex]
|
|
||||||
) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return selectedBaseSerie.breakdownSeries[selectedBreakdownIndex];
|
|
||||||
}, [selectedBaseSerie, selectedBreakdownIndex]);
|
|
||||||
|
|
||||||
// Reset breakdown selection when base serie changes
|
// Reset breakdown selection when serie changes
|
||||||
const handleBaseSerieChange = (value: string) => {
|
const handleSerieChange = (value: string) => {
|
||||||
setSelectedBaseSerieId(value);
|
setSelectedSerieId(value);
|
||||||
setSelectedBreakdownIndex(null);
|
setSelectedBreakdownId(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectedSerie = selectedBreakdown || selectedBaseSerie;
|
|
||||||
|
|
||||||
const profilesQuery = useQuery(
|
const profilesQuery = useQuery(
|
||||||
trpc.chart.getProfiles.queryOptions(
|
trpc.chart.getProfiles.queryOptions(
|
||||||
{
|
{
|
||||||
projectId: report.projectId,
|
projectId: report.projectId,
|
||||||
date: date,
|
date: date,
|
||||||
series:
|
series:
|
||||||
selectedSerie &&
|
selectedReportSerie && selectedReportSerie.type === 'event'
|
||||||
selectedBaseSerie?.reportSerie &&
|
? [selectedReportSerie]
|
||||||
selectedBaseSerie.reportSerie.type === 'event'
|
|
||||||
? [selectedBaseSerie.reportSerie]
|
|
||||||
: [],
|
: [],
|
||||||
breakdowns: selectedBreakdown?.breakdowns,
|
breakdowns: selectedBreakdown?.event.breakdowns,
|
||||||
interval: report.interval,
|
interval: report.interval,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
enabled:
|
enabled: !!selectedReportSerie && selectedReportSerie.type === 'event',
|
||||||
!!selectedSerie &&
|
|
||||||
!!selectedBaseSerie?.reportSerie &&
|
|
||||||
selectedBaseSerie.reportSerie.type === 'event',
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -129,118 +209,191 @@ export default function ViewChartUsers({
|
|||||||
const profiles = profilesQuery.data ?? [];
|
const profiles = profilesQuery.data ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ModalContent>
|
<ScrollableModal
|
||||||
<ModalHeader title="View Users" />
|
header={
|
||||||
<p className="text-sm text-muted-foreground mb-4">
|
<div>
|
||||||
Users who performed actions on {new Date(date).toLocaleDateString()}
|
<ModalHeader
|
||||||
</p>
|
title="View Users"
|
||||||
<div className="flex flex-col gap-4">
|
text={`Users who performed actions on ${new Date(date).toLocaleDateString()}`}
|
||||||
{baseSeries.length > 0 && (
|
/>
|
||||||
<div className="flex flex-col gap-3">
|
{report.series.length > 0 && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="col md:row gap-2">
|
||||||
<label className="text-sm font-medium">Serie:</label>
|
|
||||||
<Select
|
<Select
|
||||||
value={selectedBaseSerieId || ''}
|
value={selectedSerieId || ''}
|
||||||
onValueChange={handleBaseSerieChange}
|
onValueChange={handleSerieChange}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-[200px]">
|
<SelectTrigger className="flex-1">
|
||||||
<SelectValue placeholder="Select Serie" />
|
<SelectValue placeholder="Select Serie" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{baseSeries.map((baseSerie) => (
|
{report.series.map((serie) => (
|
||||||
<SelectItem
|
<SelectItem key={serie.id} value={serie.id || ''}>
|
||||||
key={baseSerie.baseEventId}
|
{serie.type === 'event'
|
||||||
value={baseSerie.baseEventId}
|
? serie.displayName || serie.name
|
||||||
>
|
: serie.displayName || 'Formula'}
|
||||||
{baseSerie.baseName}
|
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
|
||||||
|
|
||||||
{selectedBaseSerie &&
|
{matchingChartSeries.length > 1 && (
|
||||||
selectedBaseSerie.breakdownSeries.length > 1 && (
|
<Select
|
||||||
<div className="flex items-center gap-2">
|
value={selectedBreakdownId || ''}
|
||||||
<label className="text-sm font-medium">Breakdown:</label>
|
onValueChange={(value) => setSelectedBreakdownId(value)}
|
||||||
<Select
|
>
|
||||||
value={selectedBreakdownIndex?.toString() || ''}
|
<SelectTrigger className="flex-1">
|
||||||
onValueChange={(value) =>
|
<SelectValue placeholder="Select Breakdown" />
|
||||||
setSelectedBreakdownIndex(
|
</SelectTrigger>
|
||||||
value ? Number.parseInt(value, 10) : null,
|
<SelectContent>
|
||||||
)
|
{matchingChartSeries
|
||||||
}
|
.sort((a, b) => b.metrics.sum - a.metrics.sum)
|
||||||
>
|
.map((serie) => (
|
||||||
<SelectTrigger className="w-[200px]">
|
<SelectItem key={serie.id} value={serie.id}>
|
||||||
<SelectValue placeholder="All Breakdowns" />
|
{Object.values(serie.event.breakdowns ?? {}).join(
|
||||||
</SelectTrigger>
|
', ',
|
||||||
<SelectContent>
|
)}
|
||||||
<SelectItem value="">All Breakdowns</SelectItem>
|
<DropdownMenuShortcut className="ml-auto">
|
||||||
{selectedBaseSerie.breakdownSeries.map((bdSerie, idx) => (
|
({serie.data.find((d) => d.date === date)?.count})
|
||||||
<SelectItem
|
</DropdownMenuShortcut>
|
||||||
key={bdSerie.serie.id}
|
|
||||||
value={idx.toString()}
|
|
||||||
>
|
|
||||||
{bdSerie.serie.names.slice(1).join(' > ') ||
|
|
||||||
'No Breakdown'}
|
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="col">
|
||||||
{profilesQuery.isLoading ? (
|
{profilesQuery.isLoading ? (
|
||||||
<div className="flex items-center justify-center py-8">
|
<div className="flex items-center justify-center py-8">
|
||||||
<div className="text-muted-foreground">Loading users...</div>
|
<div className="text-muted-foreground">Loading users...</div>
|
||||||
</div>
|
</div>
|
||||||
) : profiles.length === 0 ? (
|
|
||||||
<div className="flex items-center justify-center py-8">
|
|
||||||
<div className="text-muted-foreground">No users found</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="max-h-[60vh] overflow-y-auto">
|
<ProfileList profiles={profiles} />
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
{profiles.map((profile) => (
|
|
||||||
<div
|
|
||||||
key={profile.id}
|
|
||||||
className="flex items-center gap-3 rounded-lg border p-3"
|
|
||||||
>
|
|
||||||
{profile.avatar ? (
|
|
||||||
<img
|
|
||||||
src={profile.avatar}
|
|
||||||
alt={profile.firstName || profile.email}
|
|
||||||
className="size-10 rounded-full"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex size-10 items-center justify-center rounded-full bg-muted">
|
|
||||||
<UsersIcon size={20} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="font-medium">
|
|
||||||
{profile.firstName || profile.lastName
|
|
||||||
? `${profile.firstName || ''} ${profile.lastName || ''}`.trim()
|
|
||||||
: profile.email || 'Anonymous'}
|
|
||||||
</div>
|
|
||||||
{profile.email && (
|
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
{profile.email}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
<ButtonContainer>
|
|
||||||
<Button type="button" variant="outline" onClick={() => popModal()}>
|
|
||||||
Close
|
|
||||||
</Button>
|
|
||||||
</ButtonContainer>
|
|
||||||
</div>
|
</div>
|
||||||
</ModalContent>
|
</ScrollableModal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Funnel-specific props and component
|
||||||
|
interface FunnelUsersViewProps {
|
||||||
|
report: IChartInput;
|
||||||
|
stepIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function FunnelUsersView({ report, stepIndex }: FunnelUsersViewProps) {
|
||||||
|
const trpc = useTRPC();
|
||||||
|
const [showDropoffs, setShowDropoffs] = useState(false);
|
||||||
|
|
||||||
|
const profilesQuery = useQuery(
|
||||||
|
trpc.chart.getFunnelProfiles.queryOptions(
|
||||||
|
{
|
||||||
|
projectId: report.projectId,
|
||||||
|
startDate: report.startDate!,
|
||||||
|
endDate: report.endDate!,
|
||||||
|
range: report.range,
|
||||||
|
series: report.series,
|
||||||
|
stepIndex: stepIndex,
|
||||||
|
showDropoffs: showDropoffs,
|
||||||
|
funnelWindow: report.funnelWindow,
|
||||||
|
funnelGroup: report.funnelGroup,
|
||||||
|
breakdowns: report.breakdowns,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: stepIndex !== undefined,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const profiles = profilesQuery.data ?? [];
|
||||||
|
const isLastStep = stepIndex === report.series.length - 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollableModal
|
||||||
|
header={
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<ModalHeader
|
||||||
|
title="View Users"
|
||||||
|
text={
|
||||||
|
showDropoffs
|
||||||
|
? `Users who dropped off after step ${stepIndex + 1} of ${report.series.length}`
|
||||||
|
: `Users who completed step ${stepIndex + 1} of ${report.series.length} in the funnel`
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{!isLastStep && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowDropoffs(false)}
|
||||||
|
className={cn(
|
||||||
|
'px-3 py-1.5 text-sm rounded-md transition-colors',
|
||||||
|
!showDropoffs
|
||||||
|
? 'bg-primary text-primary-foreground'
|
||||||
|
: 'bg-muted text-muted-foreground hover:bg-muted/80',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Completed
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowDropoffs(true)}
|
||||||
|
className={cn(
|
||||||
|
'px-3 py-1.5 text-sm rounded-md transition-colors',
|
||||||
|
showDropoffs
|
||||||
|
? 'bg-primary text-primary-foreground'
|
||||||
|
: 'bg-muted text-muted-foreground hover:bg-muted/80',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Dropped Off
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{profilesQuery.isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<div className="text-muted-foreground">Loading users...</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ProfileList profiles={profiles} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollableModal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Union type for props
|
||||||
|
type ViewChartUsersProps =
|
||||||
|
| {
|
||||||
|
type: 'chart';
|
||||||
|
chartData: IChartData;
|
||||||
|
report: IChartInput;
|
||||||
|
date: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'funnel';
|
||||||
|
report: IChartInput;
|
||||||
|
stepIndex: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Main component that routes to the appropriate view
|
||||||
|
export default function ViewChartUsers(props: ViewChartUsersProps) {
|
||||||
|
if (props.type === 'funnel') {
|
||||||
|
return (
|
||||||
|
<FunnelUsersView report={props.report} stepIndex={props.stepIndex} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChartUsersView
|
||||||
|
chartData={props.chartData}
|
||||||
|
report={props.report}
|
||||||
|
date={props.date}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -339,4 +339,38 @@ button {
|
|||||||
|
|
||||||
.animate-ping-slow {
|
.animate-ping-slow {
|
||||||
animation: ping-slow 2s cubic-bezier(0, 0, 0.2, 1) infinite;
|
animation: ping-slow 2s cubic-bezier(0, 0, 0.2, 1) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollarea {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 300px; /* set any height */
|
||||||
|
overflow: hidden; /* hide native scrollbars */
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollarea-content {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
overflow-y: scroll;
|
||||||
|
padding-right: 16px; /* preserve space for custom scrollbar */
|
||||||
|
scrollbar-width: none; /* hide Firefox scrollbar */
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollarea-content::-webkit-scrollbar {
|
||||||
|
width: 8px; /* size of custom scrollbar */
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollarea-content::-webkit-scrollbar-track {
|
||||||
|
background: transparent; /* no visible track, like shadcn */
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollarea-content::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
border-radius: 9999px; /* fully rounded */
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollarea-content:hover::-webkit-scrollbar-thumb,
|
||||||
|
.scrollarea-content:active::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(0, 0, 0, 0.4); /* darken on hover/scroll */
|
||||||
}
|
}
|
||||||
@@ -507,12 +507,11 @@ export function getChartStartEndDate(
|
|||||||
}: Pick<IChartInput, 'endDate' | 'startDate' | 'range'>,
|
}: Pick<IChartInput, 'endDate' | 'startDate' | 'range'>,
|
||||||
timezone: string,
|
timezone: string,
|
||||||
) {
|
) {
|
||||||
const ranges = getDatesFromRange(range, timezone);
|
|
||||||
|
|
||||||
if (startDate && endDate) {
|
if (startDate && endDate) {
|
||||||
return { startDate: startDate, endDate: endDate };
|
return { startDate: startDate, endDate: endDate };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ranges = getDatesFromRange(range, timezone);
|
||||||
if (!startDate && endDate) {
|
if (!startDate && endDate) {
|
||||||
return { startDate: ranges.startDate, endDate: endDate };
|
return { startDate: ranges.startDate, endDate: endDate };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { ifNaN } from '@openpanel/common';
|
import { ifNaN } from '@openpanel/common';
|
||||||
import type { IChartEvent, IChartInput } from '@openpanel/validation';
|
import type {
|
||||||
|
IChartEvent,
|
||||||
|
IChartEventItem,
|
||||||
|
IChartInput,
|
||||||
|
} from '@openpanel/validation';
|
||||||
import { last, reverse, uniq } from 'ramda';
|
import { last, reverse, uniq } from 'ramda';
|
||||||
import sqlstring from 'sqlstring';
|
import sqlstring from 'sqlstring';
|
||||||
import { ch } from '../clickhouse/client';
|
import { ch } from '../clickhouse/client';
|
||||||
@@ -14,13 +18,13 @@ import {
|
|||||||
export class FunnelService {
|
export class FunnelService {
|
||||||
constructor(private client: typeof ch) {}
|
constructor(private client: typeof ch) {}
|
||||||
|
|
||||||
private getFunnelGroup(group?: string) {
|
getFunnelGroup(group?: string): [string, string] {
|
||||||
return group === 'profile_id'
|
return group === 'profile_id'
|
||||||
? [`COALESCE(nullIf(s.pid, ''), profile_id)`, 'profile_id']
|
? [`COALESCE(nullIf(s.pid, ''), profile_id)`, 'profile_id']
|
||||||
: ['session_id', 'session_id'];
|
: ['session_id', 'session_id'];
|
||||||
}
|
}
|
||||||
|
|
||||||
private getFunnelConditions(events: IChartEvent[] = []) {
|
getFunnelConditions(events: IChartEvent[] = []): string[] {
|
||||||
return events.map((event) => {
|
return events.map((event) => {
|
||||||
const { sb, getWhere } = createSqlBuilder();
|
const { sb, getWhere } = createSqlBuilder();
|
||||||
sb.where = getEventFiltersWhereClause(event.filters);
|
sb.where = getEventFiltersWhereClause(event.filters);
|
||||||
@@ -29,6 +33,70 @@ export class FunnelService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
buildFunnelCte({
|
||||||
|
projectId,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
eventSeries,
|
||||||
|
funnelWindowMilliseconds,
|
||||||
|
group,
|
||||||
|
timezone,
|
||||||
|
additionalSelects = [],
|
||||||
|
additionalGroupBy = [],
|
||||||
|
}: {
|
||||||
|
projectId: string;
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
eventSeries: IChartEvent[];
|
||||||
|
funnelWindowMilliseconds: number;
|
||||||
|
group: [string, string];
|
||||||
|
timezone: string;
|
||||||
|
additionalSelects?: string[];
|
||||||
|
additionalGroupBy?: string[];
|
||||||
|
}) {
|
||||||
|
const funnels = this.getFunnelConditions(eventSeries);
|
||||||
|
|
||||||
|
return clix(this.client, timezone)
|
||||||
|
.select([
|
||||||
|
`${group[0]} AS ${group[1]}`,
|
||||||
|
...additionalSelects,
|
||||||
|
`windowFunnel(${funnelWindowMilliseconds}, 'strict_increase')(toUInt64(toUnixTimestamp64Milli(created_at)), ${funnels.join(', ')}) AS level`,
|
||||||
|
])
|
||||||
|
.from(TABLE_NAMES.events, false)
|
||||||
|
.where('project_id', '=', projectId)
|
||||||
|
.where('created_at', 'BETWEEN', [
|
||||||
|
clix.datetime(startDate, 'toDateTime'),
|
||||||
|
clix.datetime(endDate, 'toDateTime'),
|
||||||
|
])
|
||||||
|
.where(
|
||||||
|
'name',
|
||||||
|
'IN',
|
||||||
|
eventSeries.map((e) => e.name),
|
||||||
|
)
|
||||||
|
.groupBy([group[1], ...additionalGroupBy]);
|
||||||
|
}
|
||||||
|
|
||||||
|
buildSessionsCte({
|
||||||
|
projectId,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
timezone,
|
||||||
|
}: {
|
||||||
|
projectId: string;
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
timezone: string;
|
||||||
|
}) {
|
||||||
|
return clix(this.client, timezone)
|
||||||
|
.select(['profile_id as pid', 'id as sid'])
|
||||||
|
.from(TABLE_NAMES.sessions)
|
||||||
|
.where('project_id', '=', projectId)
|
||||||
|
.where('created_at', 'BETWEEN', [
|
||||||
|
clix.datetime(startDate, 'toDateTime'),
|
||||||
|
clix.datetime(endDate, 'toDateTime'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
private fillFunnel(
|
private fillFunnel(
|
||||||
funnel: { level: number; count: number }[],
|
funnel: { level: number; count: number }[],
|
||||||
steps: number,
|
steps: number,
|
||||||
@@ -116,14 +184,16 @@ export class FunnelService {
|
|||||||
funnelGroup,
|
funnelGroup,
|
||||||
breakdowns = [],
|
breakdowns = [],
|
||||||
timezone = 'UTC',
|
timezone = 'UTC',
|
||||||
}: IChartInput & { timezone: string }) {
|
}: IChartInput & { timezone: string; events?: IChartEvent[] }) {
|
||||||
if (!startDate || !endDate) {
|
if (!startDate || !endDate) {
|
||||||
throw new Error('startDate and endDate are required');
|
throw new Error('startDate and endDate are required');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use series if available, otherwise fall back to events (backward compat)
|
// Use series if available, otherwise fall back to events (backward compat)
|
||||||
const eventSeries = (series ?? events ?? []).filter(
|
const rawSeries = (series ?? events ?? []) as IChartEventItem[];
|
||||||
(item): item is IChartEvent => item.type === 'event',
|
const eventSeries = rawSeries.filter(
|
||||||
|
(item): item is IChartEventItem & { type: 'event' } =>
|
||||||
|
item.type === 'event',
|
||||||
) as IChartEvent[];
|
) as IChartEvent[];
|
||||||
|
|
||||||
if (eventSeries.length === 0) {
|
if (eventSeries.length === 0) {
|
||||||
@@ -133,7 +203,6 @@ export class FunnelService {
|
|||||||
const funnelWindowSeconds = funnelWindow * 3600;
|
const funnelWindowSeconds = funnelWindow * 3600;
|
||||||
const funnelWindowMilliseconds = funnelWindowSeconds * 1000;
|
const funnelWindowMilliseconds = funnelWindowSeconds * 1000;
|
||||||
const group = this.getFunnelGroup(funnelGroup);
|
const group = this.getFunnelGroup(funnelGroup);
|
||||||
const funnels = this.getFunnelConditions(eventSeries);
|
|
||||||
const profileFilters = this.getProfileFilters(eventSeries);
|
const profileFilters = this.getProfileFilters(eventSeries);
|
||||||
const anyFilterOnProfile = profileFilters.length > 0;
|
const anyFilterOnProfile = profileFilters.length > 0;
|
||||||
const anyBreakdownOnProfile = breakdowns.some((b) =>
|
const anyBreakdownOnProfile = breakdowns.some((b) =>
|
||||||
@@ -141,26 +210,22 @@ export class FunnelService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Create the funnel CTE
|
// Create the funnel CTE
|
||||||
const funnelCte = clix(this.client, timezone)
|
const breakdownSelects = breakdowns.map(
|
||||||
.select([
|
(b, index) => `${getSelectPropertyKey(b.name)} as b_${index}`,
|
||||||
`${group[0]} AS ${group[1]}`,
|
);
|
||||||
...breakdowns.map(
|
const breakdownGroupBy = breakdowns.map((b, index) => `b_${index}`);
|
||||||
(b, index) => `${getSelectPropertyKey(b.name)} as b_${index}`,
|
|
||||||
),
|
const funnelCte = this.buildFunnelCte({
|
||||||
`windowFunnel(${funnelWindowMilliseconds}, 'strict_increase')(toUInt64(toUnixTimestamp64Milli(created_at)), ${funnels.join(', ')}) AS level`,
|
projectId,
|
||||||
])
|
startDate,
|
||||||
.from(TABLE_NAMES.events, false)
|
endDate,
|
||||||
.where('project_id', '=', projectId)
|
eventSeries,
|
||||||
.where('created_at', 'BETWEEN', [
|
funnelWindowMilliseconds,
|
||||||
clix.datetime(startDate, 'toDateTime'),
|
group,
|
||||||
clix.datetime(endDate, 'toDateTime'),
|
timezone,
|
||||||
])
|
additionalSelects: breakdownSelects,
|
||||||
.where(
|
additionalGroupBy: breakdownGroupBy,
|
||||||
'name',
|
});
|
||||||
'IN',
|
|
||||||
eventSeries.map((e) => e.name),
|
|
||||||
)
|
|
||||||
.groupBy([group[1], ...breakdowns.map((b, index) => `b_${index}`)]);
|
|
||||||
|
|
||||||
if (anyFilterOnProfile || anyBreakdownOnProfile) {
|
if (anyFilterOnProfile || anyBreakdownOnProfile) {
|
||||||
funnelCte.leftJoin(
|
funnelCte.leftJoin(
|
||||||
@@ -173,15 +238,12 @@ export class FunnelService {
|
|||||||
// Create the sessions CTE if needed
|
// Create the sessions CTE if needed
|
||||||
const sessionsCte =
|
const sessionsCte =
|
||||||
group[0] !== 'session_id'
|
group[0] !== 'session_id'
|
||||||
? clix(this.client, timezone)
|
? this.buildSessionsCte({
|
||||||
// Important to have unique field names to avoid ambiguity in the main query
|
projectId,
|
||||||
.select(['profile_id as pid', 'id as sid'])
|
startDate,
|
||||||
.from(TABLE_NAMES.sessions)
|
endDate,
|
||||||
.where('project_id', '=', projectId)
|
timezone,
|
||||||
.where('created_at', 'BETWEEN', [
|
})
|
||||||
clix.datetime(startDate, 'toDateTime'),
|
|
||||||
clix.datetime(endDate, 'toDateTime'),
|
|
||||||
])
|
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
// Base funnel query with CTEs
|
// Base funnel query with CTEs
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
getSettingsForProject,
|
getSettingsForProject,
|
||||||
} from '@openpanel/db';
|
} from '@openpanel/db';
|
||||||
import {
|
import {
|
||||||
|
type IChartEvent,
|
||||||
zChartEvent,
|
zChartEvent,
|
||||||
zChartEventFilter,
|
zChartEventFilter,
|
||||||
zChartInput,
|
zChartInput,
|
||||||
@@ -621,6 +622,122 @@ export const chartRouter = createTRPCRouter({
|
|||||||
const ids = profileIds.map((p) => p.profile_id).filter(Boolean);
|
const ids = profileIds.map((p) => p.profile_id).filter(Boolean);
|
||||||
const profiles = await getProfilesCached(ids, projectId);
|
const profiles = await getProfilesCached(ids, projectId);
|
||||||
|
|
||||||
|
return profiles;
|
||||||
|
}),
|
||||||
|
|
||||||
|
getFunnelProfiles: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
projectId: z.string(),
|
||||||
|
startDate: z.string().nullish(),
|
||||||
|
endDate: z.string().nullish(),
|
||||||
|
series: zChartSeries,
|
||||||
|
stepIndex: z.number().describe('0-based index of the funnel step'),
|
||||||
|
showDropoffs: z
|
||||||
|
.boolean()
|
||||||
|
.optional()
|
||||||
|
.default(false)
|
||||||
|
.describe(
|
||||||
|
'If true, show users who dropped off at this step. If false, show users who completed at least this step.',
|
||||||
|
),
|
||||||
|
funnelWindow: z.number().optional(),
|
||||||
|
funnelGroup: z.string().optional(),
|
||||||
|
breakdowns: z.array(z.object({ name: z.string() })).optional(),
|
||||||
|
range: zRange,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.query(async ({ input }) => {
|
||||||
|
const { timezone } = await getSettingsForProject(input.projectId);
|
||||||
|
const {
|
||||||
|
projectId,
|
||||||
|
series,
|
||||||
|
stepIndex,
|
||||||
|
showDropoffs = false,
|
||||||
|
funnelWindow,
|
||||||
|
funnelGroup,
|
||||||
|
breakdowns = [],
|
||||||
|
} = input;
|
||||||
|
|
||||||
|
const { startDate, endDate } = getChartStartEndDate(input, timezone);
|
||||||
|
|
||||||
|
// stepIndex is 0-based, but level is 1-based, so we need level >= stepIndex + 1
|
||||||
|
const targetLevel = stepIndex + 1;
|
||||||
|
|
||||||
|
const eventSeries = series.filter(
|
||||||
|
(item): item is typeof item & { type: 'event' } =>
|
||||||
|
item.type === 'event',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (eventSeries.length === 0) {
|
||||||
|
throw new Error('At least one event series is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const funnelWindowSeconds = (funnelWindow || 24) * 3600;
|
||||||
|
const funnelWindowMilliseconds = funnelWindowSeconds * 1000;
|
||||||
|
|
||||||
|
// Use funnel service methods
|
||||||
|
const group = funnelService.getFunnelGroup(funnelGroup);
|
||||||
|
|
||||||
|
// Create sessions CTE if needed
|
||||||
|
const sessionsCte =
|
||||||
|
group[0] !== 'session_id'
|
||||||
|
? funnelService.buildSessionsCte({
|
||||||
|
projectId,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
timezone,
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Create funnel CTE using funnel service
|
||||||
|
const funnelCte = funnelService.buildFunnelCte({
|
||||||
|
projectId,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
eventSeries: eventSeries as IChartEvent[],
|
||||||
|
funnelWindowMilliseconds,
|
||||||
|
group,
|
||||||
|
timezone,
|
||||||
|
additionalSelects: ['profile_id'],
|
||||||
|
additionalGroupBy: ['profile_id'],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build main query
|
||||||
|
const query = clix(ch, timezone);
|
||||||
|
|
||||||
|
if (sessionsCte) {
|
||||||
|
funnelCte.leftJoin('sessions s', 's.sid = events.session_id');
|
||||||
|
query.with('sessions', sessionsCte);
|
||||||
|
}
|
||||||
|
|
||||||
|
query.with('funnel', funnelCte);
|
||||||
|
|
||||||
|
// Get distinct profile IDs
|
||||||
|
query
|
||||||
|
.select(['DISTINCT profile_id'])
|
||||||
|
.from('funnel')
|
||||||
|
.where('level', '!=', 0);
|
||||||
|
|
||||||
|
if (showDropoffs) {
|
||||||
|
// Show users who dropped off at this step (completed this step but not the next)
|
||||||
|
query.where('level', '=', targetLevel);
|
||||||
|
} else {
|
||||||
|
// Show users who completed at least this step
|
||||||
|
query.where('level', '>=', targetLevel);
|
||||||
|
}
|
||||||
|
|
||||||
|
const profileIdsResult = (await query.execute()) as {
|
||||||
|
profile_id: string;
|
||||||
|
}[];
|
||||||
|
|
||||||
|
if (profileIdsResult.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch profile details
|
||||||
|
const ids = profileIdsResult.map((p) => p.profile_id).filter(Boolean);
|
||||||
|
const profiles = await getProfilesCached(ids, projectId);
|
||||||
|
|
||||||
return profiles;
|
return profiles;
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user