feature(dashboard): add conversion rate graph

This commit is contained in:
Carl-Gerhard Lindesvärd
2025-03-28 09:21:10 +01:00
parent be358ea886
commit 8a21fadc0d
23 changed files with 807 additions and 29 deletions

View File

@@ -9,7 +9,7 @@ export function createChartTooltip<
Tooltip: React.ComponentType<
{
context: PropsFromContext;
data: PropsFromTooltip;
data: PropsFromTooltip[];
} & TooltipProps<number, string>
>,
) {
@@ -24,7 +24,7 @@ export function createChartTooltip<
const InnerTooltip = (tooltip: TooltipProps<number, string>) => {
const context = useContext();
const data = tooltip.payload?.[0]?.payload;
const data = tooltip.payload?.map((p) => p.payload) ?? [];
if (!data || !tooltip.active) {
return null;

View File

@@ -53,7 +53,7 @@ export function ReportTable({
return (
<>
<Stats className="my-4">
<Stats className="my-4 grid grid-cols-1 @xl:grid-cols-3 @4xl:grid-cols-6">
<StatsCard title="Total" value={number.format(data.metrics.sum)} />
<StatsCard
title="Average"

View File

@@ -0,0 +1,183 @@
'use client';
import type { RouterOutputs } from '@/trpc/client';
import { api } from '@/trpc/client';
import { cn } from '@/utils/cn';
import { getChartColor } from '@/utils/theme';
import {
CartesianGrid,
Line,
LineChart,
ReferenceLine,
ResponsiveContainer,
XAxis,
YAxis,
} from 'recharts';
import { createChartTooltip } from '@/components/charts/chart-tooltip';
import { useFormatDateInterval } from '@/hooks/useFormatDateInterval';
import { useNumber } from '@/hooks/useNumerFormatter';
import { getPreviousMetric } from '@openpanel/common';
import type { IInterval } from '@openpanel/validation';
import { Fragment } from 'react';
import { useXAxisProps, useYAxisProps } from '../common/axis';
import { PreviousDiffIndicatorPure } from '../common/previous-diff-indicator';
import { useReportChartContext } from '../context';
interface Props {
data: RouterOutputs['chart']['conversion'];
}
export function Chart({ data }: Props) {
const {
report: {
previous,
interval,
projectId,
startDate,
endDate,
range,
lineType,
events,
},
isEditMode,
options: { hideXAxis, hideYAxis, maxDomain },
} = useReportChartContext();
const dataLength = data.current.length || 0;
const references = api.reference.getChartReferences.useQuery(
{
projectId,
startDate,
endDate,
range,
},
{
staleTime: 1000 * 60 * 10,
},
);
const xAxisProps = useXAxisProps({ interval, hide: hideXAxis });
const yAxisProps = useYAxisProps({
hide: hideYAxis,
});
return (
<TooltipProvider conversion={data} interval={interval}>
<div className={cn('h-full w-full', isEditMode && 'card p-4')}>
<ResponsiveContainer>
<LineChart>
<CartesianGrid
strokeDasharray="3 3"
horizontal={true}
vertical={false}
className="stroke-border"
/>
{references.data?.map((ref) => (
<ReferenceLine
key={ref.id}
x={ref.date.getTime()}
stroke={'#94a3b8'}
strokeDasharray={'3 3'}
label={{
value: ref.title,
position: 'centerTop',
fill: '#334155',
fontSize: 12,
}}
fontSize={10}
/>
))}
<YAxis {...yAxisProps} domain={[0, 100]} />
<XAxis {...xAxisProps} allowDuplicatedCategory={false} />
<Tooltip />
{data.current.map((serie, index) => {
const color = getChartColor(index);
return (
<Fragment key={serie.id}>
<Line
data={serie.data}
dot={false}
name={`rate_${index}`}
dataKey="rate"
stroke={color}
type={lineType}
isAnimationActive={false}
strokeWidth={2}
/>
<Line
data={serie.data}
dot={false}
name={`prev_rate_${index}`}
dataKey="previousRate"
stroke={color}
type={lineType}
isAnimationActive={false}
strokeWidth={1}
strokeOpacity={0.5}
/>
</Fragment>
);
})}
</LineChart>
</ResponsiveContainer>
</div>
</TooltipProvider>
);
}
const { Tooltip, TooltipProvider } = createChartTooltip<
NonNullable<
RouterOutputs['chart']['conversion']['current'][number]
>['data'][number],
{
conversion: RouterOutputs['chart']['conversion'];
interval: IInterval;
}
>(({ data, context }) => {
const { date } = data[0]!;
const formatDate = useFormatDateInterval(context.interval);
const number = useNumber();
return (
<>
<div className="flex justify-between gap-8 text-muted-foreground">
<div>{formatDate(date)}</div>
</div>
{context.conversion.current.map((serie, index) => {
const item = data[index];
if (!item) {
return null;
}
const prevItem =
context.conversion?.previous?.[item.serieIndex]?.data[item.index];
const title =
serie.breakdowns.length > 0
? (serie.breakdowns.join(',') ?? 'Not set')
: 'Conversion';
return (
<div className="row gap-2" key={serie.id}>
<div
className="w-[3px] rounded-full"
style={{ background: getChartColor(index) }}
/>
<div className="col flex-1 gap-1">
<div className="flex items-center gap-1">{title}</div>
<div className="flex justify-between gap-8 font-mono font-medium">
<div className="col gap-1">
<span>{number.formatWithUnit(item.rate / 100, '%')}</span>
<span className="text-muted-foreground">
({number.format(item.total)})
</span>
</div>
<PreviousDiffIndicatorPure
{...getPreviousMetric(item.rate, prevItem?.rate)}
/>
</div>
</div>
</div>
);
})}
</>
);
});

View File

@@ -0,0 +1,69 @@
import { api } from '@/trpc/client';
import { cn } from '@/utils/cn';
import { AspectContainer } from '../aspect-container';
import { ReportChartEmpty } from '../common/empty';
import { ReportChartError } from '../common/error';
import { ReportChartLoading } from '../common/loading';
import { useReportChartContext } from '../context';
import { Chart } from './chart';
import { Summary } from './summary';
export function ReportConversionChart() {
const { isLazyLoading, report } = useReportChartContext();
const res = api.chart.conversion.useQuery(report, {
keepPreviousData: true,
staleTime: 1000 * 60 * 1,
enabled: !isLazyLoading,
});
if (
isLazyLoading ||
res.isLoading ||
(res.isFetching && !res.data?.current.length)
) {
return <Loading />;
}
if (res.isError) {
return <Error />;
}
if (res.data.current.length === 0) {
return <Empty />;
}
return (
<div>
<Summary data={res.data} />
<AspectContainer>
<Chart data={res.data} />
</AspectContainer>
</div>
);
}
function Loading() {
return (
<AspectContainer>
<ReportChartLoading />
</AspectContainer>
);
}
function Error() {
return (
<AspectContainer>
<ReportChartError />
</AspectContainer>
);
}
function Empty() {
return (
<AspectContainer>
<ReportChartEmpty />
</AspectContainer>
);
}

View File

@@ -0,0 +1,228 @@
'use client';
import type { RouterOutputs } from '@/trpc/client';
import React, { useMemo } from 'react';
import { Stats, StatsCard } from '@/components/stats';
import { useNumber } from '@/hooks/useNumerFormatter';
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 { useReportChartContext } from '../context';
interface Props {
data: RouterOutputs['chart']['conversion'];
}
export function Summary({ data }: Props) {
const number = useNumber();
const { report } = useReportChartContext();
const bestConversionRateMatch = useMemo(() => {
return data.current.reduce(
(acc, serie, serieIndex) => {
const serieMax = serie.data.reduce(
(maxInSerie, item, dataIndex) => {
if (item.rate > maxInSerie.rate) {
return { rate: item.rate, serieIndex, dataIndex };
}
return maxInSerie;
},
{ rate: 0, serieIndex, dataIndex: 0 },
);
return serieMax.rate > acc.rate ? serieMax : acc;
},
{
rate: 0,
serieIndex: 0,
dataIndex: 0,
},
);
}, [data.current]);
const worstConversionRateMatch = useMemo(() => {
return data.current.reduce(
(acc, serie, serieIndex) => {
const serieMin = serie.data.reduce(
(minInSerie, item, dataIndex) => {
if (item.rate < minInSerie.rate) {
return { rate: item.rate, serieIndex, dataIndex };
}
return minInSerie;
},
{ rate: 100, serieIndex, dataIndex: 0 },
);
return serieMin.rate < acc.rate ? serieMin : acc;
},
{
rate: 100,
serieIndex: 0,
dataIndex: 0,
},
);
}, [data.current]);
const bestConversionRate =
data.current[bestConversionRateMatch.serieIndex]?.data[
bestConversionRateMatch.dataIndex
];
const worstConversionRate =
data.current[worstConversionRateMatch.serieIndex]?.data[
worstConversionRateMatch.dataIndex
];
const bestAverageConversionRateMatch = data.current.reduce(
(acc, serie) => {
const averageRate = average(serie.data.map((item) => item.rate));
return averageRate > acc.averageRate ? { serie, averageRate } : acc;
},
{ serie: data.current[0], averageRate: 0 },
);
const worstAverageConversionRateMatch = data.current.reduce(
(acc, serie) => {
const averageRate = average(serie.data.map((item) => item.rate));
return averageRate < acc.averageRate ? { serie, averageRate } : acc;
},
{ serie: data.current[0], averageRate: 100 },
);
const averageConversionRate = average(
data.current.map((serie) => {
return average(serie.data.map((item) => item.rate));
}, 0),
);
const averageConversionRatePrevious =
average(
data.previous?.map((serie) => {
return average(serie.data.map((item) => item.rate));
}) ?? [],
) ?? 0;
const sumConversions = data.current.reduce((acc, serie) => {
return acc + sum(serie.data.map((item) => item.conversions));
}, 0);
const sumConversionsPrevious = data.previous?.reduce((acc, serie) => {
return acc + sum(serie.data.map((item) => item.conversions));
}, 0);
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>
);
}
return (
<span className="text-muted-foreground">
<span className="text-foreground">
{number.formatWithUnit(item.rate / 100, '%')}
</span>{' '}
at {formatDate(new Date(item.date))}
</span>
);
};
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.events.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>
);
}

View File

@@ -31,7 +31,7 @@ type Props = {
};
};
const Metric = ({
export const Metric = ({
label,
value,
enhancer,
@@ -218,7 +218,7 @@ export function Tables({
{
name: 'Completed',
render: (item) => number.format(item.count),
className: 'text-right font-mono',
className: 'text-right font-mono hidden @xl:block',
width: '82px',
},
{
@@ -227,7 +227,7 @@ export function Tables({
item.dropoffCount !== null && item.dropoffPercent !== null
? number.format(item.dropoffCount)
: null,
className: 'text-right font-mono',
className: 'text-right font-mono hidden @xl:block',
width: '110px',
},
{
@@ -341,7 +341,8 @@ export function Chart({ data }: { data: RouterOutputs['chart']['funnel'] }) {
const { Tooltip, TooltipProvider } = createChartTooltip<
RechartData,
Record<string, unknown>
>(({ data }) => {
>(({ data: dataArray }) => {
const data = dataArray[0]!;
const number = useNumber();
const variants = Object.keys(data).filter((key) =>
key.startsWith('step:data:'),

View File

@@ -8,6 +8,7 @@ import { ReportAreaChart } from './area';
import { ReportBarChart } from './bar';
import type { ReportChartProps } from './context';
import { ReportChartProvider } from './context';
import { ReportConversionChart } from './conversion';
import { ReportFunnelChart } from './funnel';
import { ReportHistogramChart } from './histogram';
import { ReportLineChart } from './line';
@@ -51,6 +52,8 @@ export function ReportChart(props: ReportChartProps) {
return <ReportFunnelChart />;
case 'retention':
return <ReportRetentionChart />;
case 'conversion':
return <ReportConversionChart />;
default:
return null;
}

View File

@@ -9,6 +9,7 @@ import {
LineChartIcon,
type LucideIcon,
PieChartIcon,
TrendingUpIcon,
UsersIcon,
} from 'lucide-react';
@@ -52,6 +53,7 @@ export function ReportChartType({ className }: ReportChartTypeProps) {
metric: GaugeIcon,
retention: UsersIcon,
map: Globe2Icon,
conversion: TrendingUpIcon,
};
return (
@@ -76,10 +78,11 @@ export function ReportChartType({ className }: ReportChartTypeProps) {
<DropdownMenuItem
key={item.value}
onClick={() => dispatch(changeChartType(item.value))}
className="group"
>
{item.label}
<DropdownMenuShortcut>
<Icon className="size-4" />
<Icon className="size-4 group-hover:text-blue-500 group-hover:scale-125 transition-all group-hover:rotate-12" />
</DropdownMenuShortcut>
</DropdownMenuItem>
);

View File

@@ -22,7 +22,8 @@ export function ReportInterval({ className }: ReportIntervalProps) {
chartType !== 'histogram' &&
chartType !== 'area' &&
chartType !== 'metric' &&
chartType !== 'retention'
chartType !== 'retention' &&
chartType !== 'conversion'
) {
return null;
}

View File

@@ -15,9 +15,12 @@ export function ReportLineType({ className }: ReportLineTypeProps) {
const chartType = useSelector((state) => state.report.chartType);
const type = useSelector((state) => state.report.lineType);
if (chartType !== 'linear' && chartType !== 'area') {
if (
chartType !== 'conversion' &&
chartType !== 'linear' &&
chartType !== 'area'
)
return null;
}
return (
<Combobox

View File

@@ -33,7 +33,8 @@ export function ReportEvents() {
const showAddFilter = !['retention'].includes(chartType);
const showDisplayNameInput = !['retention'].includes(chartType);
const isAddEventDisabled =
chartType === 'retention' && selectedEvents.length >= 2;
(chartType === 'retention' || chartType === 'conversion') &&
selectedEvents.length >= 2;
const dispatchChangeEvent = useDebounceFn((event: IChartEvent) => {
dispatch(changeEvent(event));
});

View File

@@ -9,7 +9,10 @@ import { ReportSettings } from './ReportSettings';
export function ReportSidebar() {
const { chartType } = useSelector((state) => state.report);
const showFormula = chartType !== 'funnel' && chartType !== 'retention';
const showFormula =
chartType !== 'conversion' &&
chartType !== 'funnel' &&
chartType !== 'retention';
const showBreakdown = chartType !== 'retention';
return (
<>

View File

@@ -10,21 +10,25 @@ export function Stats({
return (
<div className="@container">
<div
className={cn(
'grid overflow-hidden rounded border bg-background @xl:grid-cols-3 @4xl:grid-cols-6',
className,
)}
className={cn('overflow-hidden rounded border bg-card', className)}
{...props}
/>
</div>
);
}
export function StatsCard({ title, value }: { title: string; value: string }) {
export function StatsCard({
title,
value,
enhancer,
}: { title: string; value: React.ReactNode; enhancer?: React.ReactNode }) {
return (
<div className="col gap-2 p-4 ring-[0.5px] ring-border">
<div className="text-muted-foreground">{title}</div>
<div className="truncate font-mono text-2xl font-bold">{value}</div>
<div className="text-muted-foreground text-sm">{title}</div>
<div className="row justify-between gap-4">
<div className="font-mono text-lg font-bold leading-snug">{value}</div>
<div>{enhancer}</div>
</div>
</div>
);
}

View File

@@ -80,7 +80,7 @@ export function WidgetTable<T>({
<div
key={keyExtractor(item)}
className={cn(
'group/row relative border-b border-border last:border-0 h-8',
'group/row relative border-b border-border last:border-0 h-8 overflow-hidden',
columnClassName,
)}
>