fix(dashboard): improvements for both funnel and conversion chart
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { op } from '@/utils/op';
|
import { op } from '@/utils/op';
|
||||||
import { useLocation, useRouteContext } from '@tanstack/react-router';
|
import { useRouteContext } from '@tanstack/react-router';
|
||||||
import { SparklesIcon } from 'lucide-react';
|
import { SparklesIcon } from 'lucide-react';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
|
|
||||||
@@ -12,13 +12,15 @@ export function FeedbackButton() {
|
|||||||
icon={SparklesIcon}
|
icon={SparklesIcon}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
op.track('feedback_button_clicked');
|
op.track('feedback_button_clicked');
|
||||||
if ('uj' in window) {
|
if ('uj' in window && window.uj !== undefined) {
|
||||||
(window.uj as any).identify({
|
(window as any).uj.identify({
|
||||||
id: context.session?.userId,
|
id: context.session?.userId,
|
||||||
firstName: context.session?.user?.firstName,
|
firstName: context.session?.user?.firstName,
|
||||||
email: context.session?.user?.email,
|
email: context.session?.user?.email,
|
||||||
});
|
});
|
||||||
(window.uj as any).showWidget();
|
setTimeout(() => {
|
||||||
|
(window as any).uj.showWidget();
|
||||||
|
}, 10);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ 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 { getChartColor } from '@/utils/theme';
|
import { getChartColor } from '@/utils/theme';
|
||||||
import React, { useCallback } from 'react';
|
import React, { useCallback, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
CartesianGrid,
|
CartesianGrid,
|
||||||
Legend,
|
Legend,
|
||||||
@@ -62,8 +62,27 @@ export function Chart({ data }: Props) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const xAxisProps = useXAxisProps({ interval, hide: hideXAxis });
|
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({
|
const yAxisProps = useYAxisProps({
|
||||||
hide: hideYAxis,
|
hide: hideYAxis,
|
||||||
|
tickFormatter: (value: number) => {
|
||||||
|
return `${number.short(value)}%`;
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const averageConversionRate = average(
|
const averageConversionRate = average(
|
||||||
@@ -72,6 +91,9 @@ export function Chart({ data }: Props) {
|
|||||||
}, 0),
|
}, 0),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Show dots when we have 30 or fewer data points
|
||||||
|
const showDots = rechartData.length <= 30;
|
||||||
|
|
||||||
const handleChartClick = useCallback((e: any) => {
|
const handleChartClick = useCallback((e: any) => {
|
||||||
if (e?.activePayload?.[0]) {
|
if (e?.activePayload?.[0]) {
|
||||||
const clickedData = e.activePayload[0].payload;
|
const clickedData = e.activePayload[0].payload;
|
||||||
@@ -137,7 +159,7 @@ export function Chart({ data }: Props) {
|
|||||||
fontSize={10}
|
fontSize={10}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<YAxis {...yAxisProps} domain={[0, 100]} />
|
<YAxis {...yAxisProps} domain={yAxisDomain} />
|
||||||
<XAxis {...xAxisProps} allowDuplicatedCategory={false} />
|
<XAxis {...xAxisProps} allowDuplicatedCategory={false} />
|
||||||
{series.length > 1 && <Legend content={<CustomLegend />} />}
|
{series.length > 1 && <Legend content={<CustomLegend />} />}
|
||||||
<Tooltip />
|
<Tooltip />
|
||||||
@@ -166,6 +188,8 @@ export function Chart({ data }: Props) {
|
|||||||
type={lineType}
|
type={lineType}
|
||||||
isAnimationActive={false}
|
isAnimationActive={false}
|
||||||
strokeWidth={2}
|
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)}
|
stroke={getChartColor(series.length)}
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
strokeDasharray="3 3"
|
strokeDasharray="3 3"
|
||||||
strokeOpacity={0.5}
|
strokeOpacity={0.6}
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
label={{
|
label={{
|
||||||
value: `Average (${round(averageConversionRate, 2)} %)`,
|
value: `Average (${round(averageConversionRate, 2)}%)`,
|
||||||
fill: getChartColor(series.length),
|
fill: getChartColor(series.length),
|
||||||
position: 'insideBottomRight',
|
position: 'insideBottomRight',
|
||||||
fontSize: 12,
|
fontSize: 13,
|
||||||
|
fontWeight: 500,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,14 +1,33 @@
|
|||||||
import type { RouterOutputs } from '@/trpc/client';
|
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 { useNumber } from '@/hooks/use-numer-formatter';
|
||||||
import { formatDate } from '@/utils/date';
|
import { formatDate } from '@/utils/date';
|
||||||
import { average, getPreviousMetric, sum } from '@openpanel/common';
|
import { average, sum } from '@openpanel/common';
|
||||||
import { ChevronRightIcon } from 'lucide-react';
|
|
||||||
import { PreviousDiffIndicatorPure } from '../common/previous-diff-indicator';
|
|
||||||
import { useReportChartContext } from '../context';
|
import { useReportChartContext } from '../context';
|
||||||
|
|
||||||
|
const SUMMARY_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||||
|
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 {
|
interface Props {
|
||||||
data: RouterOutputs['chart']['conversion'];
|
data: RouterOutputs['chart']['conversion'];
|
||||||
}
|
}
|
||||||
@@ -108,122 +127,99 @@ export function Summary({ data }: Props) {
|
|||||||
|
|
||||||
const hasManySeries = data.current.length > 1;
|
const hasManySeries = data.current.length > 1;
|
||||||
|
|
||||||
const getConversionRateNode = (
|
const keyValueData = useMemo(() => {
|
||||||
item: RouterOutputs['chart']['conversion']['current'][0]['data'][0],
|
const flowLabel = report.series
|
||||||
) => {
|
.filter((item) => item.type === 'event')
|
||||||
const breakdowns = item.serie.breakdowns.join(', ');
|
.map((e) => e.displayName || e.name)
|
||||||
if (breakdowns) {
|
.join(' → ');
|
||||||
return (
|
const items: { name: string; value: React.ReactNode }[] = [
|
||||||
<span className="text-muted-foreground">
|
{ name: 'Flow', value: flowLabel },
|
||||||
On{' '}
|
{
|
||||||
<span className="text-foreground">
|
name: 'Average conversion rate',
|
||||||
{item.serie.breakdowns.join(', ')}
|
value: number.formatWithUnit(averageConversionRate / 100, '%'),
|
||||||
</span>{' '}
|
},
|
||||||
with{' '}
|
{ name: 'Total conversions', value: sumConversions },
|
||||||
<span className="text-foreground">
|
];
|
||||||
{number.formatWithUnit(item.rate / 100, '%')}
|
if (data.previous != null) {
|
||||||
</span>{' '}
|
items.push(
|
||||||
at {formatDate(new Date(item.date))}
|
{
|
||||||
</span>
|
name: 'Previous period average conversion rate',
|
||||||
|
value: number.formatWithUnit(
|
||||||
|
averageConversionRatePrevious / 100,
|
||||||
|
'%',
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Previous period total conversions',
|
||||||
|
value: sumConversionsPrevious ?? 0,
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (hasManySeries && bestAverageConversionRateMatch) {
|
||||||
return (
|
items.push({
|
||||||
<span className="text-muted-foreground">
|
name: 'Best breakdown (avg)',
|
||||||
<span className="text-foreground">
|
value: `${bestAverageConversionRateMatch.serie?.breakdowns.join(', ')} with ${number.formatWithUnit(bestAverageConversionRateMatch.averageRate / 100, '%')}`,
|
||||||
{number.formatWithUnit(item.rate / 100, '%')}
|
});
|
||||||
</span>{' '}
|
}
|
||||||
at {formatDate(new Date(item.date))}
|
if (hasManySeries && worstAverageConversionRateMatch) {
|
||||||
</span>
|
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 (
|
return (
|
||||||
<Stats className="my-4 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
|
<div className="my-4 space-y-3">
|
||||||
<StatsCard
|
<div className="row flex-wrap gap-2">
|
||||||
title="Flow"
|
{keyValueData.map((item) => {
|
||||||
value={
|
const Icon = SUMMARY_ICONS[item.name];
|
||||||
<div className="row flex-wrap gap-1">
|
return (
|
||||||
{report.series
|
<div
|
||||||
.filter((item) => item.type === 'event')
|
key={item.name}
|
||||||
.map((event, index) => {
|
className="card row items-center justify-between p-4 py-3 font-medium gap-4"
|
||||||
return (
|
>
|
||||||
<div key={event.id} className="row items-center gap-2">
|
<span className="text-muted-foreground row items-center gap-2">
|
||||||
{index !== 0 && <ChevronRightIcon className="size-3" />}
|
{Icon != null && <Icon className="size-4 shrink-0" />}
|
||||||
<span>{event.name}</span>
|
{item.name}
|
||||||
</div>
|
</span>
|
||||||
);
|
<span>{item.value}</span>
|
||||||
})}
|
</div>
|
||||||
</div>
|
);
|
||||||
}
|
})}
|
||||||
/>
|
</div>
|
||||||
{bestAverageConversionRateMatch && hasManySeries && (
|
</div>
|
||||||
<StatsCard
|
|
||||||
title="Best breakdown (avg)"
|
|
||||||
value={
|
|
||||||
<span>
|
|
||||||
{bestAverageConversionRateMatch.serie?.breakdowns.join(', ')}{' '}
|
|
||||||
<span className="text-muted-foreground">with</span>{' '}
|
|
||||||
{number.formatWithUnit(
|
|
||||||
bestAverageConversionRateMatch.averageRate / 100,
|
|
||||||
'%',
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{worstAverageConversionRateMatch && hasManySeries && (
|
|
||||||
<StatsCard
|
|
||||||
title="Worst breakdown (avg)"
|
|
||||||
value={
|
|
||||||
<span>
|
|
||||||
{worstAverageConversionRateMatch.serie?.breakdowns.join(', ')}{' '}
|
|
||||||
<span className="text-muted-foreground">with</span>{' '}
|
|
||||||
{number.formatWithUnit(
|
|
||||||
worstAverageConversionRateMatch.averageRate / 100,
|
|
||||||
'%',
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<StatsCard
|
|
||||||
title="Average conversion rate"
|
|
||||||
value={number.formatWithUnit(averageConversionRate / 100, '%')}
|
|
||||||
enhancer={
|
|
||||||
data.previous && (
|
|
||||||
<PreviousDiffIndicatorPure
|
|
||||||
{...getPreviousMetric(
|
|
||||||
averageConversionRate,
|
|
||||||
averageConversionRatePrevious,
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<StatsCard
|
|
||||||
title="Total conversions"
|
|
||||||
value={number.format(sumConversions)}
|
|
||||||
enhancer={
|
|
||||||
data.previous && (
|
|
||||||
<PreviousDiffIndicatorPure
|
|
||||||
{...getPreviousMetric(sumConversions, sumConversionsPrevious)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
{bestConversionRate && (
|
|
||||||
<StatsCard
|
|
||||||
title="Best conversion rate"
|
|
||||||
value={getConversionRateNode(bestConversionRate)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{worstConversionRate && (
|
|
||||||
<StatsCard
|
|
||||||
title="Worst conversion rate"
|
|
||||||
value={getConversionRateNode(worstConversionRate)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Stats>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
170
apps/start/src/components/report-chart/funnel/breakdown-list.tsx
Normal file
170
apps/start/src/components/report-chart/funnel/breakdown-list.tsx
Normal file
@@ -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<React.SetStateAction<string[]>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Set<string>>(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 (
|
||||||
|
<div className="col gap-4">
|
||||||
|
{allBreakdowns.map((item, index) => (
|
||||||
|
<Tables
|
||||||
|
key={item.id}
|
||||||
|
data={{
|
||||||
|
current: item,
|
||||||
|
previous: previousData[index] ?? null,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compact mode: > COMPACT_THRESHOLD breakdowns, show compact rows with expand
|
||||||
|
return (
|
||||||
|
<div className="col gap-2">
|
||||||
|
{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 (
|
||||||
|
<div key={item.id} className="col">
|
||||||
|
{/* Compact row */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'card row items-center gap-3 px-4 py-3 text-left w-full',
|
||||||
|
isExpanded && 'rounded-b-none',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Chart visibility checkbox */}
|
||||||
|
{hasBreakdowns && (
|
||||||
|
<Checkbox
|
||||||
|
checked={isVisible}
|
||||||
|
onCheckedChange={() => toggleVisibility(item.id)}
|
||||||
|
className="shrink-0"
|
||||||
|
style={{
|
||||||
|
borderColor: color,
|
||||||
|
backgroundColor: isVisible ? color : 'transparent',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Expandable row content */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleExpanded(item.id)}
|
||||||
|
className="flex items-center gap-3 flex-1 min-w-0 hover:opacity-80 transition-opacity"
|
||||||
|
>
|
||||||
|
{isExpanded ? (
|
||||||
|
<ChevronDown className="size-4 shrink-0 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="size-4 shrink-0 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
<span className="font-medium truncate">
|
||||||
|
{hasBreakdownName
|
||||||
|
? item.breakdowns.join(' > ')
|
||||||
|
: 'Funnel'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-6 shrink-0">
|
||||||
|
<div className="text-right row gap-2 items-center">
|
||||||
|
<div className="text-muted-foreground text-sm">
|
||||||
|
Conversion
|
||||||
|
</div>
|
||||||
|
<div className="font-mono font-semibold text-sm">
|
||||||
|
{number.formatWithUnit(
|
||||||
|
item.lastStep.percent / 100,
|
||||||
|
'%',
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right row gap-2 items-center">
|
||||||
|
<div className="text-muted-foreground text-sm">
|
||||||
|
Completed
|
||||||
|
</div>
|
||||||
|
<div className="font-mono font-semibold text-sm">
|
||||||
|
{number.format(item.lastStep.count)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expanded detailed view */}
|
||||||
|
{isExpanded && (
|
||||||
|
<Tables
|
||||||
|
data={{
|
||||||
|
current: item,
|
||||||
|
previous: previousItem,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,19 +12,24 @@ import { BarShapeBlue, BarShapeProps } from '@/components/charts/common-bar';
|
|||||||
import { Tooltiper } from '@/components/ui/tooltip';
|
import { Tooltiper } from '@/components/ui/tooltip';
|
||||||
import { WidgetTable } from '@/components/widget-table';
|
import { WidgetTable } from '@/components/widget-table';
|
||||||
import { useNumber } from '@/hooks/use-numer-formatter';
|
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||||
|
import type { IVisibleFunnelBreakdowns } from '@/hooks/use-visible-funnel-breakdowns';
|
||||||
import { getChartColor, getChartTranslucentColor } from '@/utils/theme';
|
import { getChartColor, getChartTranslucentColor } from '@/utils/theme';
|
||||||
import { getPreviousMetric } from '@openpanel/common';
|
import { getPreviousMetric } from '@openpanel/common';
|
||||||
|
import { useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
Bar,
|
Bar,
|
||||||
BarChart,
|
BarChart,
|
||||||
CartesianGrid,
|
CartesianGrid,
|
||||||
Cell,
|
Cell,
|
||||||
|
Legend,
|
||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
XAxis,
|
XAxis,
|
||||||
YAxis,
|
YAxis,
|
||||||
} 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 { SerieIcon } from '../common/serie-icon';
|
||||||
|
import { SerieName } from '../common/serie-name';
|
||||||
import { useReportChartContext } from '../context';
|
import { useReportChartContext } from '../context';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -54,7 +59,11 @@ export const Metric = ({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
export function Summary({ data }: { data: RouterOutputs['chart']['funnel'] }) {
|
export function Summary({
|
||||||
|
data,
|
||||||
|
}: {
|
||||||
|
data: RouterOutputs['chart']['funnel'];
|
||||||
|
}) {
|
||||||
const number = useNumber();
|
const number = useNumber();
|
||||||
const highestConversion = data.current
|
const highestConversion = data.current
|
||||||
.slice(0)
|
.slice(0)
|
||||||
@@ -144,23 +153,23 @@ export function Tables({
|
|||||||
|
|
||||||
// For funnels, we need to pass the step index so the modal can query
|
// 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
|
// users who completed at least that step in the funnel sequence
|
||||||
pushModal('ViewChartUsers', {
|
pushModal('ViewChartUsers', {
|
||||||
type: 'funnel',
|
type: 'funnel',
|
||||||
report: {
|
report: {
|
||||||
projectId,
|
projectId,
|
||||||
series: reportSeries,
|
series: reportSeries,
|
||||||
breakdowns: reportBreakdowns || [],
|
breakdowns: reportBreakdowns || [],
|
||||||
interval: interval || 'day',
|
interval: interval || 'day',
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
range,
|
range,
|
||||||
previous,
|
previous,
|
||||||
chartType: 'funnel',
|
chartType: 'funnel',
|
||||||
metric: 'sum',
|
metric: 'sum',
|
||||||
options: funnelOptions,
|
options: funnelOptions,
|
||||||
},
|
},
|
||||||
stepIndex, // Pass the step index for funnel queries
|
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')}>
|
||||||
@@ -330,25 +339,46 @@ type RechartData = {
|
|||||||
const useRechartData = ({
|
const useRechartData = ({
|
||||||
current,
|
current,
|
||||||
previous,
|
previous,
|
||||||
}: RouterOutputs['chart']['funnel']): RechartData[] => {
|
visibleBreakdowns,
|
||||||
|
}: RouterOutputs['chart']['funnel'] & {
|
||||||
|
visibleBreakdowns: RouterOutputs['chart']['funnel']['current'];
|
||||||
|
}): RechartData[] => {
|
||||||
const firstFunnel = current[0];
|
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<number, number>();
|
||||||
|
let visibleIndex = 0;
|
||||||
|
current.forEach((item, originalIndex) => {
|
||||||
|
if (visibleBreakdownIds.has(item.id)) {
|
||||||
|
originalToVisibleIndex.set(originalIndex, visibleIndex);
|
||||||
|
visibleIndex++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
firstFunnel?.steps.map((step, stepIndex) => {
|
firstFunnel?.steps.map((step, stepIndex) => {
|
||||||
return {
|
return {
|
||||||
id: step?.event.id ?? '',
|
id: step?.event.id ?? '',
|
||||||
name: step?.event.displayName ?? '',
|
name: step?.event.displayName ?? '',
|
||||||
...current.reduce((acc, item, index) => {
|
...visibleBreakdowns.reduce((acc, visibleItem, visibleIdx) => {
|
||||||
const diff = previous?.[index];
|
// 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 {
|
return {
|
||||||
...acc,
|
...acc,
|
||||||
[`step:percent:${index}`]: item.steps[stepIndex]?.percent ?? null,
|
[`step:percent:${visibleIdx}`]:
|
||||||
[`step:data:${index}`]: {
|
visibleItem.steps[stepIndex]?.percent ?? null,
|
||||||
...item,
|
[`step:data:${visibleIdx}`]: {
|
||||||
step: item.steps[stepIndex],
|
...visibleItem,
|
||||||
|
step: visibleItem.steps[stepIndex],
|
||||||
},
|
},
|
||||||
[`prev_step:percent:${index}`]:
|
[`prev_step:percent:${visibleIdx}`]:
|
||||||
diff?.steps[stepIndex]?.percent ?? null,
|
diff?.steps[stepIndex]?.percent ?? null,
|
||||||
[`prev_step:data:${index}`]: diff
|
[`prev_step:data:${visibleIdx}`]: diff
|
||||||
? {
|
? {
|
||||||
...diff,
|
...diff,
|
||||||
step: diff?.steps?.[stepIndex],
|
step: diff?.steps?.[stepIndex],
|
||||||
@@ -361,14 +391,51 @@ const useRechartData = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export function Chart({ data }: { data: RouterOutputs['chart']['funnel'] }) {
|
export function Chart({
|
||||||
const rechartData = useRechartData(data);
|
data,
|
||||||
|
visibleBreakdowns,
|
||||||
|
}: {
|
||||||
|
data: RouterOutputs['chart']['funnel'];
|
||||||
|
visibleBreakdowns: RouterOutputs['chart']['funnel']['current'];
|
||||||
|
}) {
|
||||||
|
const rechartData = useRechartData({ ...data, visibleBreakdowns });
|
||||||
const xAxisProps = useXAxisProps();
|
const xAxisProps = useXAxisProps();
|
||||||
const yAxisProps = useYAxisProps();
|
const yAxisProps = useYAxisProps();
|
||||||
const hasBreakdowns = data.current.length > 1;
|
const hasBreakdowns = data.current.length > 1;
|
||||||
|
const hasVisibleBreakdowns = visibleBreakdowns.length > 1;
|
||||||
|
|
||||||
|
const CustomLegend = useCallback(() => {
|
||||||
|
if (!hasVisibleBreakdowns) return null;
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap justify-center gap-x-4 gap-y-1 text-xs mt-4 -mb-2">
|
||||||
|
{visibleBreakdowns.map((breakdown, idx) => (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-1"
|
||||||
|
key={breakdown.id}
|
||||||
|
style={{
|
||||||
|
color: getChartColor(idx),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SerieIcon name={breakdown.breakdowns ?? []} />
|
||||||
|
<SerieName
|
||||||
|
name={
|
||||||
|
breakdown.breakdowns && breakdown.breakdowns.length > 0
|
||||||
|
? breakdown.breakdowns
|
||||||
|
: ['Funnel']
|
||||||
|
}
|
||||||
|
className="font-semibold"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}, [visibleBreakdowns, hasVisibleBreakdowns]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider data={data.current}>
|
<TooltipProvider
|
||||||
|
data={data.current}
|
||||||
|
visibleBreakdownIds={new Set(visibleBreakdowns.map((b) => b.id))}
|
||||||
|
>
|
||||||
<div className="aspect-video max-h-[250px] w-full p-4 card pb-1">
|
<div className="aspect-video max-h-[250px] w-full p-4 card pb-1">
|
||||||
<ResponsiveContainer>
|
<ResponsiveContainer>
|
||||||
<BarChart data={rechartData}>
|
<BarChart data={rechartData}>
|
||||||
@@ -395,7 +462,7 @@ export function Chart({ data }: { data: RouterOutputs['chart']['funnel'] }) {
|
|||||||
/>
|
/>
|
||||||
<YAxis {...yAxisProps} />
|
<YAxis {...yAxisProps} />
|
||||||
{hasBreakdowns ? (
|
{hasBreakdowns ? (
|
||||||
data.current.map((item, breakdownIndex) => (
|
visibleBreakdowns.map((item, breakdownIndex) => (
|
||||||
<Bar
|
<Bar
|
||||||
key={`step:percent:${item.id}`}
|
key={`step:percent:${item.id}`}
|
||||||
dataKey={`step:percent:${breakdownIndex}`}
|
dataKey={`step:percent:${breakdownIndex}`}
|
||||||
@@ -425,6 +492,7 @@ export function Chart({ data }: { data: RouterOutputs['chart']['funnel'] }) {
|
|||||||
))}
|
))}
|
||||||
</Bar>
|
</Bar>
|
||||||
)}
|
)}
|
||||||
|
{hasVisibleBreakdowns && <Legend content={<CustomLegend />} />}
|
||||||
<Tooltip />
|
<Tooltip />
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
@@ -437,6 +505,7 @@ const { Tooltip, TooltipProvider } = createChartTooltip<
|
|||||||
RechartData,
|
RechartData,
|
||||||
{
|
{
|
||||||
data: RouterOutputs['chart']['funnel']['current'];
|
data: RouterOutputs['chart']['funnel']['current'];
|
||||||
|
visibleBreakdownIds: Set<string>;
|
||||||
}
|
}
|
||||||
>(({ data: dataArray, context, ...props }) => {
|
>(({ data: dataArray, context, ...props }) => {
|
||||||
const data = dataArray[0]!;
|
const data = dataArray[0]!;
|
||||||
@@ -449,24 +518,37 @@ const { Tooltip, TooltipProvider } = createChartTooltip<
|
|||||||
(step) => step.event.id === (data as any).id,
|
(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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex justify-between gap-8 text-muted-foreground">
|
<div className="flex justify-between gap-8 text-muted-foreground">
|
||||||
<div>{data.name}</div>
|
<div>{data.name}</div>
|
||||||
</div>
|
</div>
|
||||||
{variants.map((key, breakdownIndex) => {
|
{visibleVariants.map((key, visibleIndex) => {
|
||||||
const variant = data[key];
|
const variant = data[key];
|
||||||
const prevVariant = data[`prev_${key}`];
|
const prevVariant = data[`prev_${key}`];
|
||||||
if (!variant?.step) {
|
if (!variant?.step) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
// Find the original breakdown index for color
|
||||||
|
const originalBreakdownIndex = context.data.findIndex(
|
||||||
|
(b) => b.id === variant.id,
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<div className="row gap-2" key={key}>
|
<div className="row gap-2" key={key}>
|
||||||
<div
|
<div
|
||||||
className="w-[3px] rounded-full"
|
className="w-[3px] rounded-full"
|
||||||
style={{
|
style={{
|
||||||
background: getChartColor(
|
background: getChartColor(
|
||||||
variants.length > 1 ? breakdownIndex : index,
|
visibleVariants.length > 1 ? visibleIndex : index,
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import { ReportChartEmpty } from '../common/empty';
|
|||||||
import { ReportChartError } from '../common/error';
|
import { ReportChartError } from '../common/error';
|
||||||
import { ReportChartLoading } from '../common/loading';
|
import { ReportChartLoading } from '../common/loading';
|
||||||
import { useReportChartContext } from '../context';
|
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() {
|
export function ReportFunnelChart() {
|
||||||
const { isLazyLoading, report, shareId } = useReportChartContext();
|
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) {
|
if (isLazyLoading || res.isLoading) {
|
||||||
return <Loading />;
|
return <Loading />;
|
||||||
}
|
}
|
||||||
@@ -36,19 +42,17 @@ export function ReportFunnelChart() {
|
|||||||
return <Empty />;
|
return <Empty />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hasBreakdowns = res.data.current.length > 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="col gap-4">
|
<div className="col gap-4">
|
||||||
{res.data.current.length > 1 && <Summary data={res.data} />}
|
{hasBreakdowns && <Summary data={res.data} />}
|
||||||
<Chart data={res.data} />
|
<Chart data={res.data} visibleBreakdowns={visibleBreakdowns} />
|
||||||
{res.data.current.map((item, index) => (
|
<BreakdownList
|
||||||
<Tables
|
data={res.data}
|
||||||
key={item.id}
|
visibleSeriesIds={visibleBreakdowns.map((b) => b.id)}
|
||||||
data={{
|
setVisibleSeries={setVisibleSeries}
|
||||||
current: item,
|
/>
|
||||||
previous: res.data.previous?.[index] ?? null,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
|
||||||
<Combobox
|
|
||||||
searchable
|
|
||||||
placeholder="Select a filter"
|
|
||||||
value=""
|
|
||||||
items={properties}
|
|
||||||
onChange={(value) => {
|
|
||||||
dispatch(
|
|
||||||
changeEvent({
|
|
||||||
...event,
|
|
||||||
property: value,
|
|
||||||
type: 'event',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={cn(
|
|
||||||
'flex items-center gap-1 rounded-md border border-border p-1 px-2 text-sm font-medium leading-none',
|
|
||||||
!event.property && 'border-destructive text-destructive',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<DatabaseIcon size={12} />{' '}
|
|
||||||
{event.property ? `Property: ${event.property}` : 'Select property'}
|
|
||||||
</button>
|
|
||||||
</Combobox>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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<HTMLDivElement>) {
|
|
||||||
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 (
|
|
||||||
<div ref={setNodeRef} style={style} {...attributes} {...props}>
|
|
||||||
<div className="flex items-center gap-2 p-2 group">
|
|
||||||
<button className="cursor-grab active:cursor-grabbing" {...listeners}>
|
|
||||||
<ColorSquare className="relative">
|
|
||||||
<HandIcon className="size-3 opacity-0 scale-50 group-hover:opacity-100 group-hover:scale-100 transition-all absolute inset-1" />
|
|
||||||
<span className="block group-hover:opacity-0 group-hover:scale-0 transition-all">
|
|
||||||
{alphabetIds[index]}
|
|
||||||
</span>
|
|
||||||
</ColorSquare>
|
|
||||||
</button>
|
|
||||||
{props.children}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Segment and Filter buttons - only for events */}
|
|
||||||
{isEvent && (showSegment || showAddFilter) && (
|
|
||||||
<div className="flex gap-2 p-2 pt-0">
|
|
||||||
{showSegment && (
|
|
||||||
<ReportSegment
|
|
||||||
value={event.segment}
|
|
||||||
onChange={(segment) => {
|
|
||||||
dispatch(
|
|
||||||
changeEvent({
|
|
||||||
...event,
|
|
||||||
segment,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{showAddFilter && (
|
|
||||||
<PropertiesCombobox
|
|
||||||
event={event}
|
|
||||||
onSelect={(action) => {
|
|
||||||
dispatch(
|
|
||||||
changeEvent({
|
|
||||||
...event,
|
|
||||||
filters: [
|
|
||||||
...event.filters,
|
|
||||||
{
|
|
||||||
id: shortId(),
|
|
||||||
name: action.value,
|
|
||||||
operator: 'is',
|
|
||||||
value: [],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{(setOpen) => (
|
|
||||||
<button
|
|
||||||
onClick={() => setOpen((p) => !p)}
|
|
||||||
type="button"
|
|
||||||
className="flex items-center gap-1 rounded-md border border-border bg-card p-1 px-2 text-sm font-medium leading-none"
|
|
||||||
>
|
|
||||||
<FilterIcon size={12} /> Add filter
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</PropertiesCombobox>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showSegment && event.segment.startsWith('property_') && (
|
|
||||||
<EventPropertiesCombobox event={event} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Filters - only for events */}
|
|
||||||
{isEvent && !isSelectManyEvents && <FiltersList event={event} />}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<div>
|
|
||||||
<h3 className="mb-2 font-medium">Metrics</h3>
|
|
||||||
<DndContext
|
|
||||||
sensors={sensors}
|
|
||||||
collisionDetection={closestCenter}
|
|
||||||
onDragEnd={handleDragEnd}
|
|
||||||
>
|
|
||||||
<SortableContext
|
|
||||||
items={selectedEvents.map((e) => ({ id: e.id! }))}
|
|
||||||
strategy={verticalListSortingStrategy}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
{selectedEvents.map((event, index) => {
|
|
||||||
const isFormula = event.type === 'formula';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SortableEvent
|
|
||||||
key={event.id}
|
|
||||||
event={event}
|
|
||||||
index={index}
|
|
||||||
showSegment={showSegment}
|
|
||||||
showAddFilter={showAddFilter}
|
|
||||||
isSelectManyEvents={isSelectManyEvents}
|
|
||||||
className="rounded-lg border bg-def-100"
|
|
||||||
>
|
|
||||||
{isFormula ? (
|
|
||||||
<>
|
|
||||||
<div className="flex-1 flex flex-col gap-2">
|
|
||||||
<InputEnter
|
|
||||||
placeholder="eg: A+B, A/B"
|
|
||||||
value={event.formula}
|
|
||||||
onChangeValue={(value) => {
|
|
||||||
dispatchChangeFormula({
|
|
||||||
...event,
|
|
||||||
formula: value,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{showDisplayNameInput && (
|
|
||||||
<Input
|
|
||||||
placeholder={`Formula (${alphabetIds[index]})`}
|
|
||||||
defaultValue={event.displayName}
|
|
||||||
onChange={(e) => {
|
|
||||||
dispatchChangeFormula({
|
|
||||||
...event,
|
|
||||||
displayName: e.target.value,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<ReportEventMore onClick={handleMore(event)} />
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<ComboboxEvents
|
|
||||||
className="flex-1"
|
|
||||||
searchable
|
|
||||||
multiple={isSelectManyEvents as false}
|
|
||||||
value={
|
|
||||||
isSelectManyEvents
|
|
||||||
? (event.filters[0]?.value ?? [])
|
|
||||||
: (event.name as any)
|
|
||||||
}
|
|
||||||
onChange={(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 && (
|
|
||||||
<Input
|
|
||||||
placeholder={
|
|
||||||
event.name
|
|
||||||
? `${event.name} (${alphabetIds[index]})`
|
|
||||||
: 'Display name'
|
|
||||||
}
|
|
||||||
defaultValue={event.displayName}
|
|
||||||
onChange={(e) => {
|
|
||||||
dispatchChangeEvent({
|
|
||||||
...event,
|
|
||||||
displayName: e.target.value,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<ReportEventMore onClick={handleMore(event)} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</SortableEvent>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<ComboboxEvents
|
|
||||||
disabled={isAddEventDisabled}
|
|
||||||
value={''}
|
|
||||||
searchable
|
|
||||||
onChange={(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 && (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
icon={PlusIcon}
|
|
||||||
onClick={() => {
|
|
||||||
dispatch(
|
|
||||||
addSerie({
|
|
||||||
type: 'formula',
|
|
||||||
formula: '',
|
|
||||||
displayName: '',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Add Formula
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</SortableContext>
|
|
||||||
</DndContext>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -3,10 +3,9 @@ import { useDispatch } from '@/redux';
|
|||||||
import { shortId } from '@openpanel/common';
|
import { shortId } from '@openpanel/common';
|
||||||
import { alphabetIds } from '@openpanel/constants';
|
import { alphabetIds } from '@openpanel/constants';
|
||||||
import type { IChartEvent, IChartEventItem } from '@openpanel/validation';
|
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 { ReportSegment } from '../ReportSegment';
|
||||||
import { changeEvent } from '../reportSlice';
|
import { changeEvent } from '../reportSlice';
|
||||||
import { EventPropertiesCombobox } from './EventPropertiesCombobox';
|
|
||||||
import { PropertiesCombobox } from './PropertiesCombobox';
|
import { PropertiesCombobox } from './PropertiesCombobox';
|
||||||
import { FiltersList } from './filters/FiltersList';
|
import { FiltersList } from './filters/FiltersList';
|
||||||
|
|
||||||
@@ -90,19 +89,40 @@ export function ReportSeriesItem({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{(setOpen) => (
|
{(setOpen) => (
|
||||||
<button
|
<SmallButton
|
||||||
onClick={() => setOpen((p) => !p)}
|
onClick={() => setOpen((p) => !p)}
|
||||||
type="button"
|
icon={FilterIcon}
|
||||||
className="flex items-center gap-1 rounded-md border border-border bg-card p-1 px-2 text-sm font-medium leading-none"
|
|
||||||
>
|
>
|
||||||
<FilterIcon size={12} /> Add filter
|
Add filter
|
||||||
</button>
|
</SmallButton>
|
||||||
)}
|
)}
|
||||||
</PropertiesCombobox>
|
</PropertiesCombobox>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showSegment && chartEvent.segment.startsWith('property_') && (
|
{showSegment && chartEvent.segment.startsWith('property_') && (
|
||||||
<EventPropertiesCombobox event={chartEvent} />
|
<PropertiesCombobox
|
||||||
|
event={chartEvent}
|
||||||
|
onSelect={(item) => {
|
||||||
|
dispatch(
|
||||||
|
changeEvent({
|
||||||
|
...chartEvent,
|
||||||
|
property: item.value,
|
||||||
|
type: 'event',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(setOpen) => (
|
||||||
|
<SmallButton
|
||||||
|
icon={DatabaseIcon}
|
||||||
|
onClick={() => setOpen((p) => !p)}
|
||||||
|
>
|
||||||
|
{chartEvent.property
|
||||||
|
? `Property: ${chartEvent.property}`
|
||||||
|
: 'Select property'}
|
||||||
|
</SmallButton>
|
||||||
|
)}
|
||||||
|
</PropertiesCombobox>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -112,3 +132,23 @@ export function ReportSeriesItem({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SmallButton({
|
||||||
|
children,
|
||||||
|
icon: Icon,
|
||||||
|
...props
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
icon: LucideIcon;
|
||||||
|
} & React.ButtonHTMLAttributes<HTMLButtonElement>) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex items-center gap-1 rounded-md border border-border bg-card p-1 px-2 text-sm font-medium leading-none text-left min-w-0"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<Icon size={12} className="shrink-0" />
|
||||||
|
<span className="truncate">{children}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,12 +21,40 @@ export function StatsCard({
|
|||||||
title,
|
title,
|
||||||
value,
|
value,
|
||||||
enhancer,
|
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 (
|
return (
|
||||||
<div className="col gap-2 p-4 ring-[0.5px] ring-border">
|
<div
|
||||||
<div className="text-muted-foreground text-sm">{title}</div>
|
className={cn(
|
||||||
|
'col ring-[0.5px] ring-border',
|
||||||
|
size === 'sm' ? 'gap-1 p-3' : 'gap-2 p-4',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'text-muted-foreground',
|
||||||
|
size === 'sm' ? 'text-xs' : 'text-sm',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
<div className="row justify-between gap-4">
|
<div className="row justify-between gap-4">
|
||||||
<div className="font-mono text-lg font-bold leading-snug">{value}</div>
|
<div
|
||||||
|
className={cn(
|
||||||
|
'font-mono leading-snug',
|
||||||
|
size === 'sm' ? 'text-sm font-medium' : 'text-lg font-bold',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
<div>{enhancer}</div>
|
<div>{enhancer}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
32
apps/start/src/hooks/use-visible-funnel-breakdowns.ts
Normal file
32
apps/start/src/hooks/use-visible-funnel-breakdowns.ts
Normal file
@@ -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<string[]>(
|
||||||
|
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]);
|
||||||
|
}
|
||||||
@@ -236,8 +236,22 @@ export class FunnelService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (anyFilterOnProfile || anyBreakdownOnProfile) {
|
if (anyFilterOnProfile || anyBreakdownOnProfile) {
|
||||||
|
// Collect profile columns needed for filters and breakdowns (same as conversion.service)
|
||||||
|
const profileFields = new Set<string>(['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(
|
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`,
|
WHERE project_id = ${sqlstring.escape(projectId)}) as profile`,
|
||||||
'profile.id = events.profile_id',
|
'profile.id = events.profile_id',
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -286,6 +286,7 @@ export const chartRouter = createTRPCRouter({
|
|||||||
}
|
}
|
||||||
|
|
||||||
properties.push(
|
properties.push(
|
||||||
|
'duration',
|
||||||
'revenue',
|
'revenue',
|
||||||
'has_profile',
|
'has_profile',
|
||||||
'path',
|
'path',
|
||||||
|
|||||||
Reference in New Issue
Block a user