fix(dashboard): improvements for both funnel and conversion chart

This commit is contained in:
Carl-Gerhard Lindesvärd
2026-02-06 08:22:57 +00:00
parent 40b0774ef8
commit fc3b6fb891
13 changed files with 579 additions and 632 deletions

View File

@@ -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);
}
}}
>

View File

@@ -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}
/>
))}
<YAxis {...yAxisProps} domain={[0, 100]} />
<YAxis {...yAxisProps} domain={yAxisDomain} />
<XAxis {...xAxisProps} allowDuplicatedCategory={false} />
{series.length > 1 && <Legend content={<CustomLegend />} />}
<Tooltip />
@@ -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,
}}
/>
)}

View File

@@ -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<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 {
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 (
<span className="text-muted-foreground">
On{' '}
<span className="text-foreground">
{item.serie.breakdowns.join(', ')}
</span>{' '}
with{' '}
<span className="text-foreground">
{number.formatWithUnit(item.rate / 100, '%')}
</span>{' '}
at {formatDate(new Date(item.date))}
</span>
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 (
<span className="text-muted-foreground">
<span className="text-foreground">
{number.formatWithUnit(item.rate / 100, '%')}
</span>{' '}
at {formatDate(new Date(item.date))}
</span>
);
};
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 (
<Stats className="my-4 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
<StatsCard
title="Flow"
value={
<div className="row flex-wrap gap-1">
{report.series
.filter((item) => item.type === 'event')
.map((event, index) => {
return (
<div key={event.id} className="row items-center gap-2">
{index !== 0 && <ChevronRightIcon className="size-3" />}
<span>{event.name}</span>
</div>
);
})}
</div>
}
/>
{bestAverageConversionRateMatch && hasManySeries && (
<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>
<div className="my-4 space-y-3">
<div className="row flex-wrap gap-2">
{keyValueData.map((item) => {
const Icon = SUMMARY_ICONS[item.name];
return (
<div
key={item.name}
className="card row items-center justify-between p-4 py-3 font-medium gap-4"
>
<span className="text-muted-foreground row items-center gap-2">
{Icon != null && <Icon className="size-4 shrink-0" />}
{item.name}
</span>
<span>{item.value}</span>
</div>
);
})}
</div>
</div>
);
}

View 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>
);
}

View File

@@ -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 = ({
</div>
);
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 (
<div className={cn('col @container divide-y divide-border card')}>
@@ -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<number, number>();
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 (
<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 (
<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">
<ResponsiveContainer>
<BarChart data={rechartData}>
@@ -395,7 +462,7 @@ export function Chart({ data }: { data: RouterOutputs['chart']['funnel'] }) {
/>
<YAxis {...yAxisProps} />
{hasBreakdowns ? (
data.current.map((item, breakdownIndex) => (
visibleBreakdowns.map((item, breakdownIndex) => (
<Bar
key={`step:percent:${item.id}`}
dataKey={`step:percent:${breakdownIndex}`}
@@ -425,6 +492,7 @@ export function Chart({ data }: { data: RouterOutputs['chart']['funnel'] }) {
))}
</Bar>
)}
{hasVisibleBreakdowns && <Legend content={<CustomLegend />} />}
<Tooltip />
</BarChart>
</ResponsiveContainer>
@@ -437,6 +505,7 @@ const { Tooltip, TooltipProvider } = createChartTooltip<
RechartData,
{
data: RouterOutputs['chart']['funnel']['current'];
visibleBreakdownIds: Set<string>;
}
>(({ 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 (
<>
<div className="flex justify-between gap-8 text-muted-foreground">
<div>{data.name}</div>
</div>
{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 (
<div className="row gap-2" key={key}>
<div
className="w-[3px] rounded-full"
style={{
background: getChartColor(
variants.length > 1 ? breakdownIndex : index,
visibleVariants.length > 1 ? visibleIndex : index,
),
}}
/>

View File

@@ -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 <Loading />;
}
@@ -36,19 +42,17 @@ export function ReportFunnelChart() {
return <Empty />;
}
const hasBreakdowns = res.data.current.length > 1;
return (
<div className="col gap-4">
{res.data.current.length > 1 && <Summary data={res.data} />}
<Chart data={res.data} />
{res.data.current.map((item, index) => (
<Tables
key={item.id}
data={{
current: item,
previous: res.data.previous?.[index] ?? null,
}}
/>
))}
{hasBreakdowns && <Summary data={res.data} />}
<Chart data={res.data} visibleBreakdowns={visibleBreakdowns} />
<BreakdownList
data={res.data}
visibleSeriesIds={visibleBreakdowns.map((b) => b.id)}
setVisibleSeries={setVisibleSeries}
/>
</div>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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) => (
<button
<SmallButton
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"
icon={FilterIcon}
>
<FilterIcon size={12} /> Add filter
</button>
Add filter
</SmallButton>
)}
</PropertiesCombobox>
)}
{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>
)}
@@ -112,3 +132,23 @@ export function ReportSeriesItem({
</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>
);
}

View File

@@ -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 (
<div className="col gap-2 p-4 ring-[0.5px] ring-border">
<div className="text-muted-foreground text-sm">{title}</div>
<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="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>
</div>