diff --git a/apps/start/src/components/feedback-button.tsx b/apps/start/src/components/feedback-button.tsx index 0bc70e97..2c4d33f5 100644 --- a/apps/start/src/components/feedback-button.tsx +++ b/apps/start/src/components/feedback-button.tsx @@ -1,5 +1,5 @@ import { op } from '@/utils/op'; -import { useLocation, useRouteContext } from '@tanstack/react-router'; +import { useRouteContext } from '@tanstack/react-router'; import { SparklesIcon } from 'lucide-react'; import { Button } from './ui/button'; @@ -12,13 +12,15 @@ export function FeedbackButton() { icon={SparklesIcon} onClick={() => { op.track('feedback_button_clicked'); - if ('uj' in window) { - (window.uj as any).identify({ + if ('uj' in window && window.uj !== undefined) { + (window as any).uj.identify({ id: context.session?.userId, firstName: context.session?.user?.firstName, email: context.session?.user?.email, }); - (window.uj as any).showWidget(); + setTimeout(() => { + (window as any).uj.showWidget(); + }, 10); } }} > diff --git a/apps/start/src/components/report-chart/conversion/chart.tsx b/apps/start/src/components/report-chart/conversion/chart.tsx index 0506fb92..c427916f 100644 --- a/apps/start/src/components/report-chart/conversion/chart.tsx +++ b/apps/start/src/components/report-chart/conversion/chart.tsx @@ -2,7 +2,7 @@ import { pushModal } from '@/modals'; import type { RouterOutputs } from '@/trpc/client'; import { cn } from '@/utils/cn'; import { getChartColor } from '@/utils/theme'; -import React, { useCallback } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { CartesianGrid, Legend, @@ -62,8 +62,27 @@ export function Chart({ data }: Props) { ); const xAxisProps = useXAxisProps({ interval, hide: hideXAxis }); + const number = useNumber(); + + // Calculate dynamic Y-axis domain based on max rate + const yAxisDomain = useMemo(() => { + if (!series.length) return [0, 100]; + + const maxRate = Math.max( + ...series.flatMap((serie) => serie.data.map((item) => item.rate)) + ); + + if (maxRate <= 5) return [0, 10]; + if (maxRate <= 20) return [0, 30]; + if (maxRate <= 50) return [0, 60]; + return [0, 100]; + }, [series]); + const yAxisProps = useYAxisProps({ hide: hideYAxis, + tickFormatter: (value: number) => { + return `${number.short(value)}%`; + }, }); const averageConversionRate = average( @@ -72,6 +91,9 @@ export function Chart({ data }: Props) { }, 0), ); + // Show dots when we have 30 or fewer data points + const showDots = rechartData.length <= 30; + const handleChartClick = useCallback((e: any) => { if (e?.activePayload?.[0]) { const clickedData = e.activePayload[0].payload; @@ -137,7 +159,7 @@ export function Chart({ data }: Props) { fontSize={10} /> ))} - + {series.length > 1 && } />} @@ -166,6 +188,8 @@ export function Chart({ data }: Props) { type={lineType} isAnimationActive={false} strokeWidth={2} + dot={showDots ? { r: 3, strokeWidth: 2, fill: 'white' } : false} + activeDot={showDots ? { r: 5, strokeWidth: 2 } : { r: 4 }} /> ); })} @@ -176,13 +200,14 @@ export function Chart({ data }: Props) { stroke={getChartColor(series.length)} strokeWidth={2} strokeDasharray="3 3" - strokeOpacity={0.5} + strokeOpacity={0.6} strokeLinecap="round" label={{ - value: `Average (${round(averageConversionRate, 2)} %)`, + value: `Average (${round(averageConversionRate, 2)}%)`, fill: getChartColor(series.length), position: 'insideBottomRight', - fontSize: 12, + fontSize: 13, + fontWeight: 500, }} /> )} diff --git a/apps/start/src/components/report-chart/conversion/summary.tsx b/apps/start/src/components/report-chart/conversion/summary.tsx index 60dc97de..fe6ba42d 100644 --- a/apps/start/src/components/report-chart/conversion/summary.tsx +++ b/apps/start/src/components/report-chart/conversion/summary.tsx @@ -1,14 +1,33 @@ import type { RouterOutputs } from '@/trpc/client'; -import React, { useMemo } from 'react'; +import type React from 'react'; +import { useMemo } from 'react'; +import { + ArrowDownRight, + ArrowUpRight, + GitBranch, + Hash, + Percent, + Target, + Trophy, +} from 'lucide-react'; -import { Stats, StatsCard } from '@/components/stats'; import { useNumber } from '@/hooks/use-numer-formatter'; import { formatDate } from '@/utils/date'; -import { average, getPreviousMetric, sum } from '@openpanel/common'; -import { ChevronRightIcon } from 'lucide-react'; -import { PreviousDiffIndicatorPure } from '../common/previous-diff-indicator'; +import { average, sum } from '@openpanel/common'; import { useReportChartContext } from '../context'; +const SUMMARY_ICONS: Record> = { + Flow: GitBranch, + 'Average conversion rate': Percent, + 'Total conversions': Target, + 'Previous period average conversion rate': Percent, + 'Previous period total conversions': Hash, + 'Best breakdown (avg)': Trophy, + 'Worst breakdown (avg)': ArrowDownRight, + 'Best conversion rate': ArrowUpRight, + 'Worst conversion rate': ArrowDownRight, +}; + interface Props { data: RouterOutputs['chart']['conversion']; } @@ -108,122 +127,99 @@ export function Summary({ data }: Props) { const hasManySeries = data.current.length > 1; - const getConversionRateNode = ( - item: RouterOutputs['chart']['conversion']['current'][0]['data'][0], - ) => { - const breakdowns = item.serie.breakdowns.join(', '); - if (breakdowns) { - return ( - - On{' '} - - {item.serie.breakdowns.join(', ')} - {' '} - with{' '} - - {number.formatWithUnit(item.rate / 100, '%')} - {' '} - at {formatDate(new Date(item.date))} - + const keyValueData = useMemo(() => { + const flowLabel = report.series + .filter((item) => item.type === 'event') + .map((e) => e.displayName || e.name) + .join(' → '); + const items: { name: string; value: React.ReactNode }[] = [ + { name: 'Flow', value: flowLabel }, + { + name: 'Average conversion rate', + value: number.formatWithUnit(averageConversionRate / 100, '%'), + }, + { name: 'Total conversions', value: sumConversions }, + ]; + if (data.previous != null) { + items.push( + { + name: 'Previous period average conversion rate', + value: number.formatWithUnit( + averageConversionRatePrevious / 100, + '%', + ), + }, + { + name: 'Previous period total conversions', + value: sumConversionsPrevious ?? 0, + }, ); } - - return ( - - - {number.formatWithUnit(item.rate / 100, '%')} - {' '} - at {formatDate(new Date(item.date))} - - ); - }; + if (hasManySeries && bestAverageConversionRateMatch) { + items.push({ + name: 'Best breakdown (avg)', + value: `${bestAverageConversionRateMatch.serie?.breakdowns.join(', ')} with ${number.formatWithUnit(bestAverageConversionRateMatch.averageRate / 100, '%')}`, + }); + } + if (hasManySeries && worstAverageConversionRateMatch) { + items.push({ + name: 'Worst breakdown (avg)', + value: `${worstAverageConversionRateMatch.serie?.breakdowns.join(', ')} with ${number.formatWithUnit(worstAverageConversionRateMatch.averageRate / 100, '%')}`, + }); + } + if (bestConversionRate) { + const breakdowns = bestConversionRate.serie.breakdowns.join(', '); + items.push({ + name: 'Best conversion rate', + value: breakdowns + ? `${number.formatWithUnit(bestConversionRate.rate / 100, '%')} on ${breakdowns} at ${formatDate(new Date(bestConversionRate.date))}` + : `${number.formatWithUnit(bestConversionRate.rate / 100, '%')} at ${formatDate(new Date(bestConversionRate.date))}`, + }); + } + if (worstConversionRate) { + const breakdowns = worstConversionRate.serie.breakdowns.join(', '); + items.push({ + name: 'Worst conversion rate', + value: breakdowns + ? `${number.formatWithUnit(worstConversionRate.rate / 100, '%')} on ${breakdowns} at ${formatDate(new Date(worstConversionRate.date))}` + : `${number.formatWithUnit(worstConversionRate.rate / 100, '%')} at ${formatDate(new Date(worstConversionRate.date))}`, + }); + } + return items; + }, [ + report.series, + averageConversionRate, + sumConversions, + data.previous, + averageConversionRatePrevious, + sumConversionsPrevious, + hasManySeries, + bestAverageConversionRateMatch, + worstAverageConversionRateMatch, + bestConversionRate, + worstConversionRate, + number, + ]); return ( - - - {report.series - .filter((item) => item.type === 'event') - .map((event, index) => { - return ( -
- {index !== 0 && } - {event.name} -
- ); - })} - - } - /> - {bestAverageConversionRateMatch && hasManySeries && ( - - {bestAverageConversionRateMatch.serie?.breakdowns.join(', ')}{' '} - with{' '} - {number.formatWithUnit( - bestAverageConversionRateMatch.averageRate / 100, - '%', - )} - - } - /> - )} - {worstAverageConversionRateMatch && hasManySeries && ( - - {worstAverageConversionRateMatch.serie?.breakdowns.join(', ')}{' '} - with{' '} - {number.formatWithUnit( - worstAverageConversionRateMatch.averageRate / 100, - '%', - )} - - } - /> - )} - - ) - } - /> - - ) - } - /> - {bestConversionRate && ( - - )} - {worstConversionRate && ( - - )} -
+
+
+ {keyValueData.map((item) => { + const Icon = SUMMARY_ICONS[item.name]; + return ( +
+ + {Icon != null && } + {item.name} + + {item.value} +
+ ); + })} +
+
); } diff --git a/apps/start/src/components/report-chart/funnel/breakdown-list.tsx b/apps/start/src/components/report-chart/funnel/breakdown-list.tsx new file mode 100644 index 00000000..76de4b8a --- /dev/null +++ b/apps/start/src/components/report-chart/funnel/breakdown-list.tsx @@ -0,0 +1,170 @@ +import { Checkbox } from '@/components/ui/checkbox'; +import { useNumber } from '@/hooks/use-numer-formatter'; +import type { RouterOutputs } from '@/trpc/client'; +import { cn } from '@/utils/cn'; +import { getChartColor } from '@/utils/theme'; +import { ChevronDown, ChevronRight } from 'lucide-react'; +import { useState } from 'react'; +import { Tables } from './chart'; + +interface BreakdownListProps { + data: RouterOutputs['chart']['funnel']; + visibleSeriesIds: string[]; + setVisibleSeries: React.Dispatch>; +} + +const COMPACT_THRESHOLD = 4; + +export function BreakdownList({ + data, + visibleSeriesIds, + setVisibleSeries, +}: BreakdownListProps) { + const allBreakdowns = data.current; + const previousData = data.previous || []; + const isCompact = allBreakdowns.length > COMPACT_THRESHOLD; + const hasBreakdowns = allBreakdowns.length > 1; + const [expandedIds, setExpandedIds] = useState>(new Set()); + const number = useNumber(); + + const toggleExpanded = (id: string) => { + setExpandedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + }; + + const toggleVisibility = (id: string) => { + setVisibleSeries((prev) => { + if (prev.includes(id)) { + return prev.filter((s) => s !== id); + } + return [...prev, id]; + }); + }; + + // Get the color index for a breakdown based on its position in the + // visible series list (so colors match the chart bars) + const getVisibleIndex = (id: string) => { + return visibleSeriesIds.indexOf(id); + }; + + if (allBreakdowns.length === 0) { + return null; + } + + // Detailed mode: <= COMPACT_THRESHOLD breakdowns, show full Tables for each + if (!isCompact) { + return ( +
+ {allBreakdowns.map((item, index) => ( + + ))} +
+ ); + } + + // Compact mode: > COMPACT_THRESHOLD breakdowns, show compact rows with expand + return ( +
+ {allBreakdowns.map((item, index) => { + const isExpanded = expandedIds.has(item.id); + const isVisible = visibleSeriesIds.includes(item.id); + const visibleIndex = getVisibleIndex(item.id); + const previousItem = previousData[index] ?? null; + const hasBreakdownName = + item.breakdowns && item.breakdowns.length > 0; + const color = + isVisible && visibleIndex !== -1 + ? getChartColor(visibleIndex) + : undefined; + + return ( +
+ {/* Compact row */} +
+ {/* Chart visibility checkbox */} + {hasBreakdowns && ( + toggleVisibility(item.id)} + className="shrink-0" + style={{ + borderColor: color, + backgroundColor: isVisible ? color : 'transparent', + }} + /> + )} + + {/* Expandable row content */} + + +
+
+
+ Conversion +
+
+ {number.formatWithUnit( + item.lastStep.percent / 100, + '%', + )} +
+
+
+
+ Completed +
+
+ {number.format(item.lastStep.count)} +
+
+
+
+ + {/* Expanded detailed view */} + {isExpanded && ( + + )} +
+ ); + })} +
+ ); +} diff --git a/apps/start/src/components/report-chart/funnel/chart.tsx b/apps/start/src/components/report-chart/funnel/chart.tsx index 66506f0c..0a17a641 100644 --- a/apps/start/src/components/report-chart/funnel/chart.tsx +++ b/apps/start/src/components/report-chart/funnel/chart.tsx @@ -12,19 +12,24 @@ import { BarShapeBlue, BarShapeProps } from '@/components/charts/common-bar'; import { Tooltiper } from '@/components/ui/tooltip'; import { WidgetTable } from '@/components/widget-table'; import { useNumber } from '@/hooks/use-numer-formatter'; +import type { IVisibleFunnelBreakdowns } from '@/hooks/use-visible-funnel-breakdowns'; import { getChartColor, getChartTranslucentColor } from '@/utils/theme'; import { getPreviousMetric } from '@openpanel/common'; +import { useCallback } from 'react'; import { Bar, BarChart, CartesianGrid, Cell, + Legend, ResponsiveContainer, XAxis, YAxis, } from 'recharts'; import { useXAxisProps, useYAxisProps } from '../common/axis'; import { PreviousDiffIndicatorPure } from '../common/previous-diff-indicator'; +import { SerieIcon } from '../common/serie-icon'; +import { SerieName } from '../common/serie-name'; import { useReportChartContext } from '../context'; type Props = { @@ -54,7 +59,11 @@ export const Metric = ({ ); -export function Summary({ data }: { data: RouterOutputs['chart']['funnel'] }) { +export function Summary({ + data, +}: { + data: RouterOutputs['chart']['funnel']; +}) { const number = useNumber(); const highestConversion = data.current .slice(0) @@ -144,23 +153,23 @@ export function Tables({ // 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', - options: funnelOptions, - }, - stepIndex, // Pass the step index for funnel queries - }); + pushModal('ViewChartUsers', { + type: 'funnel', + report: { + projectId, + series: reportSeries, + breakdowns: reportBreakdowns || [], + interval: interval || 'day', + startDate, + endDate, + range, + previous, + chartType: 'funnel', + metric: 'sum', + options: funnelOptions, + }, + stepIndex, // Pass the step index for funnel queries + }); }; return (
@@ -330,25 +339,46 @@ type RechartData = { const useRechartData = ({ current, previous, -}: RouterOutputs['chart']['funnel']): RechartData[] => { + visibleBreakdowns, +}: RouterOutputs['chart']['funnel'] & { + visibleBreakdowns: RouterOutputs['chart']['funnel']['current']; +}): RechartData[] => { const firstFunnel = current[0]; + // Create a map of original index to visible index + const visibleBreakdownIds = new Set(visibleBreakdowns.map((b) => b.id)); + const originalToVisibleIndex = new Map(); + let visibleIndex = 0; + current.forEach((item, originalIndex) => { + if (visibleBreakdownIds.has(item.id)) { + originalToVisibleIndex.set(originalIndex, visibleIndex); + visibleIndex++; + } + }); + return ( firstFunnel?.steps.map((step, stepIndex) => { return { id: step?.event.id ?? '', name: step?.event.displayName ?? '', - ...current.reduce((acc, item, index) => { - const diff = previous?.[index]; + ...visibleBreakdowns.reduce((acc, visibleItem, visibleIdx) => { + // Find the original index for this visible breakdown + const originalIndex = current.findIndex( + (item) => item.id === visibleItem.id, + ); + if (originalIndex === -1) return acc; + + const diff = previous?.[originalIndex]; return { ...acc, - [`step:percent:${index}`]: item.steps[stepIndex]?.percent ?? null, - [`step:data:${index}`]: { - ...item, - step: item.steps[stepIndex], + [`step:percent:${visibleIdx}`]: + visibleItem.steps[stepIndex]?.percent ?? null, + [`step:data:${visibleIdx}`]: { + ...visibleItem, + step: visibleItem.steps[stepIndex], }, - [`prev_step:percent:${index}`]: + [`prev_step:percent:${visibleIdx}`]: diff?.steps[stepIndex]?.percent ?? null, - [`prev_step:data:${index}`]: diff + [`prev_step:data:${visibleIdx}`]: diff ? { ...diff, step: diff?.steps?.[stepIndex], @@ -361,14 +391,51 @@ const useRechartData = ({ ); }; -export function Chart({ data }: { data: RouterOutputs['chart']['funnel'] }) { - const rechartData = useRechartData(data); +export function Chart({ + data, + visibleBreakdowns, +}: { + data: RouterOutputs['chart']['funnel']; + visibleBreakdowns: RouterOutputs['chart']['funnel']['current']; +}) { + const rechartData = useRechartData({ ...data, visibleBreakdowns }); const xAxisProps = useXAxisProps(); const yAxisProps = useYAxisProps(); const hasBreakdowns = data.current.length > 1; + const hasVisibleBreakdowns = visibleBreakdowns.length > 1; + + const CustomLegend = useCallback(() => { + if (!hasVisibleBreakdowns) return null; + return ( +
+ {visibleBreakdowns.map((breakdown, idx) => ( +
+ + 0 + ? breakdown.breakdowns + : ['Funnel'] + } + className="font-semibold" + /> +
+ ))} +
+ ); + }, [visibleBreakdowns, hasVisibleBreakdowns]); return ( - + b.id))} + >
@@ -395,7 +462,7 @@ export function Chart({ data }: { data: RouterOutputs['chart']['funnel'] }) { /> {hasBreakdowns ? ( - data.current.map((item, breakdownIndex) => ( + visibleBreakdowns.map((item, breakdownIndex) => ( )} + {hasVisibleBreakdowns && } />} @@ -437,6 +505,7 @@ const { Tooltip, TooltipProvider } = createChartTooltip< RechartData, { data: RouterOutputs['chart']['funnel']['current']; + visibleBreakdownIds: Set; } >(({ data: dataArray, context, ...props }) => { const data = dataArray[0]!; @@ -449,24 +518,37 @@ const { Tooltip, TooltipProvider } = createChartTooltip< (step) => step.event.id === (data as any).id, ); + // Filter variants to only show visible breakdowns + // The variant object contains the full breakdown item, so we can check its ID directly + const visibleVariants = variants.filter((key) => { + const variant = data[key]; + if (!variant) return false; + // The variant is the breakdown item itself (with step added), so it has an id property + return context.visibleBreakdownIds.has(variant.id); + }); + return ( <>
{data.name}
- {variants.map((key, breakdownIndex) => { + {visibleVariants.map((key, visibleIndex) => { const variant = data[key]; const prevVariant = data[`prev_${key}`]; if (!variant?.step) { return null; } + // Find the original breakdown index for color + const originalBreakdownIndex = context.data.findIndex( + (b) => b.id === variant.id, + ); return (
1 ? breakdownIndex : index, + visibleVariants.length > 1 ? visibleIndex : index, ), }} /> diff --git a/apps/start/src/components/report-chart/funnel/index.tsx b/apps/start/src/components/report-chart/funnel/index.tsx index 24bd6043..f13a0cb5 100644 --- a/apps/start/src/components/report-chart/funnel/index.tsx +++ b/apps/start/src/components/report-chart/funnel/index.tsx @@ -7,7 +7,9 @@ import { ReportChartEmpty } from '../common/empty'; import { ReportChartError } from '../common/error'; import { ReportChartLoading } from '../common/loading'; import { useReportChartContext } from '../context'; -import { Chart, Summary, Tables } from './chart'; +import { useVisibleFunnelBreakdowns } from '@/hooks/use-visible-funnel-breakdowns'; +import { Chart, Summary } from './chart'; +import { BreakdownList } from './breakdown-list'; export function ReportFunnelChart() { const { isLazyLoading, report, shareId } = useReportChartContext(); @@ -24,6 +26,10 @@ export function ReportFunnelChart() { ), ); + // Hook for limiting which breakdowns are shown in the chart only + const { breakdowns: visibleBreakdowns, setVisibleSeries } = + useVisibleFunnelBreakdowns(res.data?.current ?? [], 10); + if (isLazyLoading || res.isLoading) { return ; } @@ -36,19 +42,17 @@ export function ReportFunnelChart() { return ; } + const hasBreakdowns = res.data.current.length > 1; + return (
- {res.data.current.length > 1 && } - - {res.data.current.map((item, index) => ( - - ))} + {hasBreakdowns && } + + b.id)} + setVisibleSeries={setVisibleSeries} + />
); } diff --git a/apps/start/src/components/report/sidebar/EventPropertiesCombobox.tsx b/apps/start/src/components/report/sidebar/EventPropertiesCombobox.tsx deleted file mode 100644 index 059c47c7..00000000 --- a/apps/start/src/components/report/sidebar/EventPropertiesCombobox.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { Combobox } from '@/components/ui/combobox'; -import { useAppParams } from '@/hooks/use-app-params'; -import { useEventProperties } from '@/hooks/use-event-properties'; -import { useDispatch } from '@/redux'; -import { cn } from '@/utils/cn'; -import { DatabaseIcon } from 'lucide-react'; - -import type { IChartEvent } from '@openpanel/validation'; - -import { changeEvent } from '../reportSlice'; - -interface EventPropertiesComboboxProps { - event: IChartEvent; -} - -export function EventPropertiesCombobox({ - event, -}: EventPropertiesComboboxProps) { - const dispatch = useDispatch(); - const { projectId } = useAppParams(); - const properties = useEventProperties( - { - event: event.name, - projectId, - }, - { - enabled: !!event.name, - }, - ).map((item) => ({ - label: item, - value: item, - })); - - return ( - { - dispatch( - changeEvent({ - ...event, - property: value, - type: 'event', - }), - ); - }} - > - - - ); -} diff --git a/apps/start/src/components/report/sidebar/ReportEvents.tsx b/apps/start/src/components/report/sidebar/ReportEvents.tsx deleted file mode 100644 index 6cb03682..00000000 --- a/apps/start/src/components/report/sidebar/ReportEvents.tsx +++ /dev/null @@ -1,385 +0,0 @@ -import { ColorSquare } from '@/components/color-square'; -import { Button } from '@/components/ui/button'; -import { ComboboxEvents } from '@/components/ui/combobox-events'; -import { Input } from '@/components/ui/input'; -import { InputEnter } from '@/components/ui/input-enter'; -import { useAppParams } from '@/hooks/use-app-params'; -import { useDebounceFn } from '@/hooks/use-debounce-fn'; -import { useEventNames } from '@/hooks/use-event-names'; -import { useDispatch, useSelector } from '@/redux'; -import { - DndContext, - type DragEndEvent, - KeyboardSensor, - PointerSensor, - closestCenter, - useSensor, - useSensors, -} from '@dnd-kit/core'; -import { - SortableContext, - sortableKeyboardCoordinates, - useSortable, - verticalListSortingStrategy, -} from '@dnd-kit/sortable'; -import { CSS } from '@dnd-kit/utilities'; -import { shortId } from '@openpanel/common'; -import { alphabetIds } from '@openpanel/constants'; -import type { IChartEventItem, IChartFormula } from '@openpanel/validation'; -import { FilterIcon, HandIcon, PlusIcon } from 'lucide-react'; -import { ReportSegment } from '../ReportSegment'; -import { - addSerie, - changeEvent, - duplicateEvent, - removeEvent, - reorderEvents, -} from '../reportSlice'; -import { EventPropertiesCombobox } from './EventPropertiesCombobox'; -import { PropertiesCombobox } from './PropertiesCombobox'; -import type { ReportEventMoreProps } from './ReportEventMore'; -import { ReportEventMore } from './ReportEventMore'; -import { FiltersList } from './filters/FiltersList'; - -function SortableEvent({ - event, - index, - showSegment, - showAddFilter, - isSelectManyEvents, - ...props -}: { - event: IChartEventItem; - index: number; - showSegment: boolean; - showAddFilter: boolean; - isSelectManyEvents: boolean; -} & React.HTMLAttributes) { - const dispatch = useDispatch(); - const { attributes, listeners, setNodeRef, transform, transition } = - useSortable({ id: event.id ?? '' }); - - const style = { - transform: CSS.Transform.toString(transform), - transition, - }; - - const isEvent = event.type === 'event'; - - return ( -
-
- - {props.children} -
- - {/* Segment and Filter buttons - only for events */} - {isEvent && (showSegment || showAddFilter) && ( -
- {showSegment && ( - { - dispatch( - changeEvent({ - ...event, - segment, - }), - ); - }} - /> - )} - {showAddFilter && ( - { - dispatch( - changeEvent({ - ...event, - filters: [ - ...event.filters, - { - id: shortId(), - name: action.value, - operator: 'is', - value: [], - }, - ], - }), - ); - }} - > - {(setOpen) => ( - - )} - - )} - - {showSegment && event.segment.startsWith('property_') && ( - - )} -
- )} - - {/* Filters - only for events */} - {isEvent && !isSelectManyEvents && } -
- ); -} - -export function ReportEvents() { - const selectedEvents = useSelector((state) => state.report.series); - const chartType = useSelector((state) => state.report.chartType); - const dispatch = useDispatch(); - const { projectId } = useAppParams(); - const eventNames = useEventNames({ - projectId, - }); - - const showSegment = !['retention', 'funnel'].includes(chartType); - const showAddFilter = !['retention'].includes(chartType); - const showDisplayNameInput = !['retention'].includes(chartType); - const isAddEventDisabled = - (chartType === 'retention' || chartType === 'conversion') && - selectedEvents.length >= 2; - const dispatchChangeEvent = useDebounceFn((event: IChartEventItem) => { - dispatch(changeEvent(event)); - }); - const isSelectManyEvents = chartType === 'retention'; - - const sensors = useSensors( - useSensor(PointerSensor), - useSensor(KeyboardSensor, { - coordinateGetter: sortableKeyboardCoordinates, - }), - ); - - const handleDragEnd = (event: DragEndEvent) => { - const { active, over } = event; - - if (over && active.id !== over.id) { - const oldIndex = selectedEvents.findIndex((e) => e.id === active.id); - const newIndex = selectedEvents.findIndex((e) => e.id === over.id); - - dispatch(reorderEvents({ fromIndex: oldIndex, toIndex: newIndex })); - } - }; - - const handleMore = (event: IChartEventItem) => { - const callback: ReportEventMoreProps['onClick'] = (action) => { - switch (action) { - case 'remove': { - return dispatch( - removeEvent({ - id: event.id, - }), - ); - } - case 'duplicate': { - return dispatch(duplicateEvent(event)); - } - } - }; - - return callback; - }; - - const dispatchChangeFormula = useDebounceFn((formula: IChartFormula) => { - dispatch(changeEvent(formula)); - }); - - const showFormula = - chartType !== 'conversion' && - chartType !== 'funnel' && - chartType !== 'retention'; - - return ( -
-

Metrics

- - ({ id: e.id! }))} - strategy={verticalListSortingStrategy} - > -
- {selectedEvents.map((event, index) => { - const isFormula = event.type === 'formula'; - - return ( - - {isFormula ? ( - <> -
- { - dispatchChangeFormula({ - ...event, - formula: value, - }); - }} - /> - {showDisplayNameInput && ( - { - dispatchChangeFormula({ - ...event, - displayName: e.target.value, - }); - }} - /> - )} -
- - - ) : ( - <> - { - dispatch( - changeEvent( - Array.isArray(value) - ? { - id: event.id, - type: 'event', - segment: 'user', - filters: [ - { - name: 'name', - operator: 'is', - value: value, - }, - ], - name: '*', - } - : { - ...event, - type: 'event', - name: value, - filters: [], - }, - ), - ); - }} - items={eventNames} - placeholder="Select event" - /> - {showDisplayNameInput && ( - { - dispatchChangeEvent({ - ...event, - displayName: e.target.value, - }); - }} - /> - )} - - - )} -
- ); - })} - -
- { - if (isSelectManyEvents) { - dispatch( - addSerie({ - type: 'event', - segment: 'user', - name: value, - filters: [ - { - name: 'name', - operator: 'is', - value: [value], - }, - ], - }), - ); - } else { - dispatch( - addSerie({ - type: 'event', - name: value, - segment: 'event', - filters: [], - }), - ); - } - }} - placeholder="Select event" - items={eventNames} - /> - {showFormula && ( - - )} -
-
-
-
-
- ); -} diff --git a/apps/start/src/components/report/sidebar/ReportSeriesItem.tsx b/apps/start/src/components/report/sidebar/ReportSeriesItem.tsx index d647df98..e6f70aa2 100644 --- a/apps/start/src/components/report/sidebar/ReportSeriesItem.tsx +++ b/apps/start/src/components/report/sidebar/ReportSeriesItem.tsx @@ -3,10 +3,9 @@ import { useDispatch } from '@/redux'; import { shortId } from '@openpanel/common'; import { alphabetIds } from '@openpanel/constants'; import type { IChartEvent, IChartEventItem } from '@openpanel/validation'; -import { FilterIcon } from 'lucide-react'; +import { DatabaseIcon, FilterIcon, type LucideIcon } from 'lucide-react'; import { ReportSegment } from '../ReportSegment'; import { changeEvent } from '../reportSlice'; -import { EventPropertiesCombobox } from './EventPropertiesCombobox'; import { PropertiesCombobox } from './PropertiesCombobox'; import { FiltersList } from './filters/FiltersList'; @@ -90,19 +89,40 @@ export function ReportSeriesItem({ }} > {(setOpen) => ( - + Add filter + )} )} {showSegment && chartEvent.segment.startsWith('property_') && ( - + { + dispatch( + changeEvent({ + ...chartEvent, + property: item.value, + type: 'event', + }), + ); + }} + > + {(setOpen) => ( + setOpen((p) => !p)} + > + {chartEvent.property + ? `Property: ${chartEvent.property}` + : 'Select property'} + + )} + )}
)} @@ -112,3 +132,23 @@ export function ReportSeriesItem({
); } + +function SmallButton({ + children, + icon: Icon, + ...props +}: { + children: React.ReactNode; + icon: LucideIcon; +} & React.ButtonHTMLAttributes) { + return ( + + ); +} diff --git a/apps/start/src/components/stats.tsx b/apps/start/src/components/stats.tsx index e5a5dce0..6c03d800 100644 --- a/apps/start/src/components/stats.tsx +++ b/apps/start/src/components/stats.tsx @@ -21,12 +21,40 @@ export function StatsCard({ title, value, enhancer, -}: { title: string; value: React.ReactNode; enhancer?: React.ReactNode }) { + className, + size = 'default', +}: { + title: string; + value: React.ReactNode; + enhancer?: React.ReactNode; + className?: string; + size?: 'default' | 'sm'; +}) { return ( -
-
{title}
+
+
+ {title} +
-
{value}
+
+ {value} +
{enhancer}
diff --git a/apps/start/src/hooks/use-visible-funnel-breakdowns.ts b/apps/start/src/hooks/use-visible-funnel-breakdowns.ts new file mode 100644 index 00000000..e6d52b70 --- /dev/null +++ b/apps/start/src/hooks/use-visible-funnel-breakdowns.ts @@ -0,0 +1,32 @@ +import type { RouterOutputs } from '@/trpc/client'; +import { useEffect, useMemo, useState } from 'react'; + +export type IVisibleFunnelBreakdowns = ReturnType< + typeof useVisibleFunnelBreakdowns +>['breakdowns']; + +export function useVisibleFunnelBreakdowns( + data: RouterOutputs['chart']['funnel']['current'], + limit?: number | undefined, +) { + const max = limit ?? 10; + const [visibleSeries, setVisibleSeries] = useState( + data?.slice(0, max).map((item) => item.id) ?? [], + ); + + useEffect(() => { + setVisibleSeries(data?.slice(0, max).map((item) => item.id) ?? []); + }, [data, max]); + + return useMemo(() => { + return { + breakdowns: data + .map((item, index) => ({ + ...item, + index, + })) + .filter((item) => visibleSeries.includes(item.id)), + setVisibleSeries, + } as const; + }, [visibleSeries, data]); +} diff --git a/packages/db/src/services/funnel.service.ts b/packages/db/src/services/funnel.service.ts index 4d0f79aa..a224a510 100644 --- a/packages/db/src/services/funnel.service.ts +++ b/packages/db/src/services/funnel.service.ts @@ -236,8 +236,22 @@ export class FunnelService { }); if (anyFilterOnProfile || anyBreakdownOnProfile) { + // Collect profile columns needed for filters and breakdowns (same as conversion.service) + const profileFields = new Set(['id']); + for (const f of profileFilters) { + profileFields.add(f.split('.')[0]!); + } + for (const b of breakdowns.filter((x) => x.name.startsWith('profile.'))) { + const fieldName = b.name.replace('profile.', '').split('.')[0]; + if (fieldName === 'properties') { + profileFields.add('properties'); + } else if (['email', 'first_name', 'last_name'].includes(fieldName!)) { + profileFields.add(fieldName!); + } + } + const profileSelectColumns = Array.from(profileFields).join(', '); funnelCte.leftJoin( - `(SELECT id, ${uniq(profileFilters.map((f) => f.split('.')[0]))} FROM ${TABLE_NAMES.profiles} FINAL + `(SELECT ${profileSelectColumns} FROM ${TABLE_NAMES.profiles} FINAL WHERE project_id = ${sqlstring.escape(projectId)}) as profile`, 'profile.id = events.profile_id', ); diff --git a/packages/trpc/src/routers/chart.ts b/packages/trpc/src/routers/chart.ts index 2305ecf4..aaa05534 100644 --- a/packages/trpc/src/routers/chart.ts +++ b/packages/trpc/src/routers/chart.ts @@ -286,6 +286,7 @@ export const chartRouter = createTRPCRouter({ } properties.push( + 'duration', 'revenue', 'has_profile', 'path',