diff --git a/apps/start/src/components/report-chart/area/chart.tsx b/apps/start/src/components/report-chart/area/chart.tsx index 08d174f9..35f692f4 100644 --- a/apps/start/src/components/report-chart/area/chart.tsx +++ b/apps/start/src/components/report-chart/area/chart.tsx @@ -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) { - pushModal('AddReference', { - datetime: new Date(clickedData.date).toISOString(), + 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: , + 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: , + 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 ( - - - + + + + )} + ); } diff --git a/apps/start/src/components/report-chart/common/chart-click-menu.tsx b/apps/start/src/components/report-chart/common/chart-click-menu.tsx new file mode 100644 index 00000000..197b0ab3 --- /dev/null +++ b/apps/start/src/components/report-chart/common/chart-click-menu.tsx @@ -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(null); + const [clickPosition, setClickPosition] = useState<{ + x: number; + y: number; + } | null>(null); + const [clickedData, setClickedData] = useState(null); + + const [clickEvent, setClickEvent] = useState(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 ( + + + + + + + {menuItems.map((item) => ( + handleItemClick(item)} + disabled={item.disabled} + > + {item.icon && {item.icon}} + {item.label} + + ))} + + + {chartWithClickHandler} + + ); +}); + +ChartClickMenu.displayName = 'ChartClickMenu'; diff --git a/apps/start/src/components/report-chart/funnel/chart.tsx b/apps/start/src/components/report-chart/funnel/chart.tsx index 96d0fff0..5c7aa4d4 100644 --- a/apps/start/src/components/report-chart/funnel/chart.tsx +++ b/apps/start/src/components/report-chart/funnel/chart.tsx @@ -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 ( {hasHeader && } @@ -128,11 +170,11 @@ export function Tables({ label="Conversion" value={number.formatWithUnit(lastStep?.percent / 100, '%')} enhancer={ - previous && ( + previousData && ( ) @@ -143,11 +185,11 @@ export function Tables({ label="Completed" value={number.format(lastStep?.count)} enhancer={ - previous && ( + previousData && ( ) @@ -238,6 +280,28 @@ export function Tables({ className: 'text-right font-mono font-semibold', width: '90px', }, + { + name: '', + render: (item) => ( + { + e.stopPropagation(); + const stepIndex = steps.findIndex( + (s) => s.event.id === item.event.id, + ); + handleInspectStep(item, stepIndex); + }} + title="View users who completed this step" + > + + + ), + className: 'text-right', + width: '48px', + }, ]} /> diff --git a/apps/start/src/components/report-chart/histogram/chart.tsx b/apps/start/src/components/report-chart/histogram/chart.tsx index 932de7f1..529ab1a2 100644 --- a/apps/start/src/components/report-chart/histogram/chart.tsx +++ b/apps/start/src/components/report-chart/histogram/chart.tsx @@ -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) { - pushModal('AddReference', { - datetime: new Date(clickedData.date).toISOString(), + 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: , + 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: , + onClick: () => { + pushModal('AddReference', { + datetime: new Date(clickedData.date).toISOString(), + }); + }, + }); + + return items; + }, + [ + projectId, + data, + reportSeries, + breakdowns, + interval, + startDate, + endDate, + range, + previous, + ], + ); return ( - - - + + + + )} + ); } diff --git a/apps/start/src/components/report-chart/line/chart.tsx b/apps/start/src/components/report-chart/line/chart.tsx index 87b6e500..d546258d 100644 --- a/apps/start/src/components/report-chart/line/chart.tsx +++ b/apps/start/src/components/report-chart/line/chart.tsx @@ -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,171 +135,146 @@ 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( - (p: any) => - p.dataKey && - p.dataKey !== 'calcStrokeDasharray' && - typeof p.dataKey === 'string' && - p.dataKey.includes(':count'), - ); - const serieId = validPayload?.dataKey?.toString().replace(':count', ''); + const getMenuItems = useCallback( + (e: any, clickedData: any): ChartClickMenuItem[] => { + const items: ChartClickMenuItem[] = []; - setClickedData({ - date: payload.date, - serieId, - }); - setClickPosition({ - x: activeCoordinate?.x ?? 0, - y: activeCoordinate?.y ?? 0, + 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' && + 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: , + 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(() => { - if (!clickedData || !projectId) return; + // Add Reference - always show + items.push({ + label: 'Add Reference', + icon: , + onClick: () => { + pushModal('AddReference', { + datetime: new Date(clickedData.date).toISOString(), + }); + }, + }); - // Pass the chart data (which we already have) and the report config - pushModal('ViewChartUsers', { - chartData: data, - report: { - projectId, - series: reportSeries, - breakdowns: breakdowns || [], - interval, - startDate, - endDate, - range, - 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 items; + }, + [ + projectId, + data, + reportSeries, + breakdowns, + interval, + startDate, + endDate, + range, + previous, + ], + ); return ( - - { - if (!open) { - setClickPosition(null); - setClickedData(null); - } - }} - > - - - - - - - View Users - - - - Add Reference - - - - - - - - - {references.data?.map((ref) => ( - + + + + + - ))} - - - {series.length > 1 && } />} - } /> - {/* {series.map((serie) => { - const color = getChartColor(serie.index); - return ( - - - {isAreaStyle && ( - - - - - )} - + + {references.data?.map((ref) => ( + + ))} + + + {series.length > 1 && } />} + } /> + + + + + + + + + + + + {series.map((serie) => { + const color = getChartColor(serie.index); + return ( - {previous && ( - - )} - - ); - })} */} + ); + })} - - - - - - - - - - - {series.map((serie) => { - const color = getChartColor(serie.index); - return ( - - ); - })} - - {/* Previous */} - {previous - ? series.map((serie) => { - const color = getChartColor(serie.index); - return ( - - ); - }) - : null} - - - - {isEditMode && ( - - )} + {/* Previous */} + {previous + ? series.map((serie) => { + const color = getChartColor(serie.index); + return ( + + ); + }) + : null} + + + + {isEditMode && ( + + )} + ); } diff --git a/apps/start/src/components/ui/scroll-area.tsx b/apps/start/src/components/ui/scroll-area.tsx index 141e9900..d737b1dc 100644 --- a/apps/start/src/components/ui/scroll-area.tsx +++ b/apps/start/src/components/ui/scroll-area.tsx @@ -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 ( + + {children} + + ); +}); + +VirtualScrollArea.displayName = 'VirtualScrollArea'; + function ScrollArea({ className, children, diff --git a/apps/start/src/modals/Modal/scrollable-modal.tsx b/apps/start/src/modals/Modal/scrollable-modal.tsx new file mode 100644 index 00000000..47edc57b --- /dev/null +++ b/apps/start/src/modals/Modal/scrollable-modal.tsx @@ -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; +}>({ + 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(null); + return ( + + + {header} + + {children} + + {footer && {footer}} + + + ); +} diff --git a/apps/start/src/modals/view-chart-users.tsx b/apps/start/src/modals/view-chart-users.tsx index 76148046..2c591c89 100644 --- a/apps/start/src/modals/view-chart-users.tsx +++ b/apps/start/src/modals/view-chart-users.tsx @@ -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 ( + { + if (e.metaKey || e.ctrlKey || e.shiftKey) { + return; + } + popModal(); + }} + > + + + + {getProfileName(profile)} + + + + + {profile.properties.country && ( + + + + {profile.properties.country} + {profile.properties.city && ` / ${profile.properties.city}`} + + + )} + {profile.properties.os && ( + + + {profile.properties.os} + + )} + {profile.properties.browser && ( + + + {profile.properties.browser} + + )} + + + ); +}; +// 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 ( + + No users found + + ); + } + + const virtualItems = virtualizer.getVirtualItems(); + + return ( + + {/* Only the visible items in the virtualizer, manually positioned to be in view */} + {virtualItems.map((virtualItem) => { + const profile = profiles[virtualItem.index]; + return ( + + + + ); + })} + + ); +} + +// 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 | 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( + const [selectedSerieId, setSelectedSerieId] = useState( + report.series[0]?.id || null, + ); + const [selectedBreakdownId, setSelectedBreakdownId] = useState( 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 ( - - - - Users who performed actions on {new Date(date).toLocaleDateString()} - - - {baseSeries.length > 0 && ( - - - Serie: + + + {report.series.length > 0 && ( + - + - {baseSeries.map((baseSerie) => ( - - {baseSerie.baseName} + {report.series.map((serie) => ( + + {serie.type === 'event' + ? serie.displayName || serie.name + : serie.displayName || 'Formula'} ))} - - {selectedBaseSerie && - selectedBaseSerie.breakdownSeries.length > 1 && ( - - Breakdown: - - setSelectedBreakdownIndex( - value ? Number.parseInt(value, 10) : null, - ) - } - > - - - - - All Breakdowns - {selectedBaseSerie.breakdownSeries.map((bdSerie, idx) => ( - - {bdSerie.serie.names.slice(1).join(' > ') || - 'No Breakdown'} + {matchingChartSeries.length > 1 && ( + setSelectedBreakdownId(value)} + > + + + + + {matchingChartSeries + .sort((a, b) => b.metrics.sum - a.metrics.sum) + .map((serie) => ( + + {Object.values(serie.event.breakdowns ?? {}).join( + ', ', + )} + + ({serie.data.find((d) => d.date === date)?.count}) + ))} - - - + + )} - - )} + + )} + + } + > + {profilesQuery.isLoading ? ( Loading users... - ) : profiles.length === 0 ? ( - - No users found - ) : ( - - - {profiles.map((profile) => ( - - {profile.avatar ? ( - - ) : ( - - - - )} - - - {profile.firstName || profile.lastName - ? `${profile.firstName || ''} ${profile.lastName || ''}`.trim() - : profile.email || 'Anonymous'} - - {profile.email && ( - - {profile.email} - - )} - - - ))} - - + )} - - popModal()}> - Close - - - + + ); +} + +// 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 ( + + + {!isLastStep && ( + + 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 + + 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 + + + )} + + } + > + + {profilesQuery.isLoading ? ( + + Loading users... + + ) : ( + + )} + + + ); +} + +// 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 ( + + ); + } + + return ( + ); } diff --git a/apps/start/src/styles.css b/apps/start/src/styles.css index 616bf314..401fbb2d 100644 --- a/apps/start/src/styles.css +++ b/apps/start/src/styles.css @@ -339,4 +339,38 @@ 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 */ } \ No newline at end of file diff --git a/packages/db/src/services/chart.service.ts b/packages/db/src/services/chart.service.ts index 5f70b65b..092986c3 100644 --- a/packages/db/src/services/chart.service.ts +++ b/packages/db/src/services/chart.service.ts @@ -507,12 +507,11 @@ export function getChartStartEndDate( }: Pick, 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 }; } diff --git a/packages/db/src/services/funnel.service.ts b/packages/db/src/services/funnel.service.ts index c7ed74ac..fb51758d 100644 --- a/packages/db/src/services/funnel.service.ts +++ b/packages/db/src/services/funnel.service.ts @@ -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( - (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 breakdownSelects = breakdowns.map( + (b, index) => `${getSelectPropertyKey(b.name)} as 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 diff --git a/packages/trpc/src/routers/chart.ts b/packages/trpc/src/routers/chart.ts index 44f9575b..1a703286 100644 --- a/packages/trpc/src/routers/chart.ts +++ b/packages/trpc/src/routers/chart.ts @@ -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; }), });
- Users who performed actions on {new Date(date).toLocaleDateString()} -