feature(dashboard): add conversion rate graph
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
183
apps/dashboard/src/components/report-chart/conversion/chart.tsx
Normal file
183
apps/dashboard/src/components/report-chart/conversion/chart.tsx
Normal 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>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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:'),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -22,7 +22,8 @@ export function ReportInterval({ className }: ReportIntervalProps) {
|
||||
chartType !== 'histogram' &&
|
||||
chartType !== 'area' &&
|
||||
chartType !== 'metric' &&
|
||||
chartType !== 'retention'
|
||||
chartType !== 'retention' &&
|
||||
chartType !== 'conversion'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -24,12 +24,21 @@ export async function deleteProjects() {
|
||||
});
|
||||
}
|
||||
|
||||
await ch.command({
|
||||
query: `DELETE FROM ${TABLE_NAMES.events} WHERE project_id IN (${projects.map((project) => escape(project.id)).join(',')});`,
|
||||
clickhouse_settings: {
|
||||
lightweight_deletes_sync: 0,
|
||||
},
|
||||
});
|
||||
if (process.env.SELF_HOSTED) {
|
||||
await ch.command({
|
||||
query: `DELETE FROM ${TABLE_NAMES.events} WHERE project_id IN (${projects.map((project) => escape(project.id)).join(',')});`,
|
||||
clickhouse_settings: {
|
||||
lightweight_deletes_sync: 0,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await ch.command({
|
||||
query: `DELETE FROM ${TABLE_NAMES.events}_replicated ON CLUSTER '{cluster}' WHERE project_id IN (${projects.map((project) => escape(project.id)).join(',')});`,
|
||||
clickhouse_settings: {
|
||||
lightweight_deletes_sync: 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
logger.info(`Deleted ${projects.length} projects`, {
|
||||
projects,
|
||||
|
||||
@@ -84,6 +84,7 @@ export const chartTypes = {
|
||||
map: 'Map',
|
||||
funnel: 'Funnel',
|
||||
retention: 'Retention',
|
||||
conversion: 'Conversion',
|
||||
} as const;
|
||||
|
||||
export const lineTypes = {
|
||||
|
||||
@@ -13,6 +13,7 @@ export * from './src/services/salt.service';
|
||||
export * from './src/services/share.service';
|
||||
export * from './src/services/session.service';
|
||||
export * from './src/services/funnel.service';
|
||||
export * from './src/services/conversion.service';
|
||||
export * from './src/services/user.service';
|
||||
export * from './src/services/reference.service';
|
||||
export * from './src/services/id.service';
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "clients" DROP CONSTRAINT "clients_organizationId_fkey";
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "clients" ADD CONSTRAINT "clients_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "ChartType" ADD VALUE 'conversion';
|
||||
@@ -230,7 +230,7 @@ model Client {
|
||||
type ClientType @default(write)
|
||||
projectId String?
|
||||
project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
organization Organization @relation(fields: [organizationId], references: [id])
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
organizationId String
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
@@ -257,6 +257,7 @@ enum ChartType {
|
||||
map
|
||||
funnel
|
||||
retention
|
||||
conversion
|
||||
}
|
||||
|
||||
model Dashboard {
|
||||
|
||||
199
packages/db/src/services/conversion.service.ts
Normal file
199
packages/db/src/services/conversion.service.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import { NOT_SET_VALUE } from '@openpanel/constants';
|
||||
import type { IChartInput } from '@openpanel/validation';
|
||||
import { omit } from 'ramda';
|
||||
import { TABLE_NAMES, ch } from '../clickhouse/client';
|
||||
import { clix } from '../clickhouse/query-builder';
|
||||
import {
|
||||
getEventFiltersWhereClause,
|
||||
getSelectPropertyKey,
|
||||
} from './chart.service';
|
||||
|
||||
export class ConversionService {
|
||||
constructor(private client: typeof ch) {}
|
||||
|
||||
async getConversion({
|
||||
projectId,
|
||||
startDate,
|
||||
endDate,
|
||||
funnelGroup,
|
||||
funnelWindow = 24,
|
||||
events,
|
||||
breakdowns = [],
|
||||
interval,
|
||||
}: Omit<IChartInput, 'range' | 'previous' | 'metric' | 'chartType'>) {
|
||||
const group = funnelGroup === 'profile_id' ? 'profile_id' : 'session_id';
|
||||
const breakdownColumns = breakdowns.map(
|
||||
(b, index) => `${getSelectPropertyKey(b.name)} as b_${index}`,
|
||||
);
|
||||
const breakdownGroupBy = breakdowns.map((b, index) => `b_${index}`);
|
||||
|
||||
if (events.length !== 2) {
|
||||
throw new Error('events must be an array of two events');
|
||||
}
|
||||
|
||||
if (!startDate || !endDate) {
|
||||
throw new Error('startDate and endDate are required');
|
||||
}
|
||||
|
||||
const eventA = events[0]!;
|
||||
const eventB = events[1]!;
|
||||
const whereA = Object.values(
|
||||
getEventFiltersWhereClause(eventA.filters),
|
||||
).join(' AND ');
|
||||
const whereB = Object.values(
|
||||
getEventFiltersWhereClause(eventB.filters),
|
||||
).join(' AND ');
|
||||
|
||||
const eventACte = clix(this.client)
|
||||
.select([
|
||||
`DISTINCT ${group}`,
|
||||
'created_at AS a_time',
|
||||
`${clix.toStartOf('created_at', interval)} AS event_day`,
|
||||
...breakdownColumns,
|
||||
])
|
||||
.from(TABLE_NAMES.events)
|
||||
.where('project_id', '=', projectId)
|
||||
.where('name', '=', eventA.name)
|
||||
.rawWhere(whereA)
|
||||
.where('created_at', 'BETWEEN', [
|
||||
clix.datetime(startDate),
|
||||
clix.datetime(endDate),
|
||||
]);
|
||||
|
||||
const eventBCte = clix(this.client)
|
||||
.select([group, 'created_at AS b_time'])
|
||||
.from(TABLE_NAMES.events)
|
||||
.where('project_id', '=', projectId)
|
||||
.where('name', '=', eventB.name)
|
||||
.rawWhere(whereB)
|
||||
.where('created_at', 'BETWEEN', [
|
||||
clix.datetime(startDate),
|
||||
clix.datetime(endDate),
|
||||
]);
|
||||
|
||||
const query = clix(this.client)
|
||||
.with('event_a', eventACte)
|
||||
.with('event_b', eventBCte)
|
||||
.select<{
|
||||
event_day: string;
|
||||
total_first: number;
|
||||
conversions: number;
|
||||
conversion_rate_percentage: number;
|
||||
[key: string]: string | number; // For breakdown columns
|
||||
}>([
|
||||
'event_day',
|
||||
...breakdownGroupBy,
|
||||
'count(*) AS total_first',
|
||||
'sum(if(conversion_time IS NOT NULL, 1, 0)) AS conversions',
|
||||
'round(100.0 * sum(if(conversion_time IS NOT NULL, 1, 0)) / count(*), 2) AS conversion_rate_percentage',
|
||||
])
|
||||
.from(
|
||||
clix.exp(`
|
||||
(SELECT
|
||||
a.${group},
|
||||
a.a_time,
|
||||
a.event_day,
|
||||
${breakdownGroupBy.length ? `${breakdownGroupBy.join(', ')},` : ''}
|
||||
nullIf(min(b.b_time), '1970-01-01 00:00:00.000') AS conversion_time
|
||||
FROM event_a AS a
|
||||
LEFT JOIN event_b AS b ON a.${group} = b.${group}
|
||||
AND b.b_time BETWEEN a.a_time AND a.a_time + INTERVAL ${funnelWindow} HOUR
|
||||
GROUP BY a.${group}, a.a_time, a.event_day${breakdownGroupBy.length ? `, ${breakdownGroupBy.join(', ')}` : ''})
|
||||
`),
|
||||
)
|
||||
.groupBy(['event_day', ...breakdownGroupBy]);
|
||||
|
||||
for (const order of ['event_day', ...breakdownGroupBy]) {
|
||||
query.orderBy(order);
|
||||
}
|
||||
|
||||
const results = await query.execute();
|
||||
return this.toSeries(results, breakdowns).map((serie, serieIndex) => {
|
||||
return {
|
||||
...serie,
|
||||
data: serie.data.map((d, index) => ({
|
||||
...d,
|
||||
timestamp: new Date(d.date).getTime(),
|
||||
serieIndex,
|
||||
index,
|
||||
serie: omit(['data'], serie),
|
||||
})),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private toSeries(
|
||||
data: {
|
||||
event_day: string;
|
||||
total_first: number;
|
||||
conversions: number;
|
||||
conversion_rate_percentage: number;
|
||||
[key: string]: string | number;
|
||||
}[],
|
||||
breakdowns: { name: string }[] = [],
|
||||
) {
|
||||
if (!breakdowns.length) {
|
||||
return [
|
||||
{
|
||||
id: 'conversion',
|
||||
breakdowns: [],
|
||||
data: data.map((d) => ({
|
||||
date: d.event_day,
|
||||
total: d.total_first,
|
||||
conversions: d.conversions,
|
||||
rate: d.conversion_rate_percentage,
|
||||
})),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// Group by breakdown values
|
||||
const series = data.reduce(
|
||||
(acc, d) => {
|
||||
const key =
|
||||
breakdowns.map((b, index) => d[`b_${index}`]).join('|') ||
|
||||
NOT_SET_VALUE;
|
||||
if (!acc[key]) {
|
||||
acc[key] = {
|
||||
id: key,
|
||||
breakdowns: breakdowns.map(
|
||||
(b, index) => (d[`b_${index}`] || NOT_SET_VALUE) as string,
|
||||
),
|
||||
data: [],
|
||||
};
|
||||
}
|
||||
acc[key]!.data.push({
|
||||
date: d.event_day,
|
||||
total: d.total_first,
|
||||
conversions: d.conversions,
|
||||
rate: d.conversion_rate_percentage,
|
||||
});
|
||||
return acc;
|
||||
},
|
||||
{} as Record<
|
||||
string,
|
||||
{
|
||||
id: string;
|
||||
breakdowns: string[];
|
||||
data: {
|
||||
date: string;
|
||||
total: number;
|
||||
conversions: number;
|
||||
rate: number;
|
||||
}[];
|
||||
}
|
||||
>,
|
||||
);
|
||||
|
||||
return Object.values(series).map((serie, serieIndex) => ({
|
||||
...serie,
|
||||
data: serie.data.map((item, dataIndex) => ({
|
||||
...item,
|
||||
dataIndex,
|
||||
serieIndex,
|
||||
})),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
export const conversionService = new ConversionService(ch);
|
||||
38
packages/db/test.ts
Normal file
38
packages/db/test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { conversionService } from './src/services/conversion.service';
|
||||
// 68/37
|
||||
async function main() {
|
||||
const conversion = await conversionService.getConversion({
|
||||
projectId: 'kiddokitchen-app',
|
||||
startDate: '2025-02-01',
|
||||
endDate: '2025-03-01',
|
||||
funnelGroup: 'session_id',
|
||||
breakdowns: [
|
||||
{
|
||||
name: 'os',
|
||||
},
|
||||
],
|
||||
interval: 'day',
|
||||
events: [
|
||||
{
|
||||
segment: 'event',
|
||||
name: 'screen_view',
|
||||
filters: [
|
||||
{
|
||||
name: 'path',
|
||||
operator: 'is',
|
||||
value: ['Start'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
segment: 'event',
|
||||
name: 'sign_up',
|
||||
filters: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
console.dir(conversion, { depth: null });
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -5,6 +5,7 @@ import { z } from 'zod';
|
||||
import {
|
||||
TABLE_NAMES,
|
||||
chQuery,
|
||||
conversionService,
|
||||
createSqlBuilder,
|
||||
db,
|
||||
funnelService,
|
||||
@@ -32,7 +33,6 @@ import {
|
||||
getChart,
|
||||
getChartPrevStartEndDate,
|
||||
getChartStartEndDate,
|
||||
getFunnelData,
|
||||
} from './chart.helpers';
|
||||
|
||||
function utc(date: string | Date) {
|
||||
@@ -197,6 +197,29 @@ export const chartRouter = createTRPCRouter({
|
||||
};
|
||||
}),
|
||||
|
||||
conversion: protectedProcedure.input(zChartInput).query(async ({ input }) => {
|
||||
const currentPeriod = getChartStartEndDate(input);
|
||||
const previousPeriod = getChartPrevStartEndDate(currentPeriod);
|
||||
|
||||
const [current, previous] = await Promise.all([
|
||||
conversionService.getConversion({ ...input, ...currentPeriod }),
|
||||
input.previous
|
||||
? conversionService.getConversion({ ...input, ...previousPeriod })
|
||||
: Promise.resolve(null),
|
||||
]);
|
||||
|
||||
return {
|
||||
current: current.map((serie, sIndex) => ({
|
||||
...serie,
|
||||
data: serie.data.map((d, dIndex) => ({
|
||||
...d,
|
||||
previousRate: previous?.[sIndex]?.data?.[dIndex]?.rate,
|
||||
})),
|
||||
})),
|
||||
previous,
|
||||
};
|
||||
}),
|
||||
|
||||
chart: publicProcedure.input(zChartInput).query(async ({ input, ctx }) => {
|
||||
if (ctx.session.userId) {
|
||||
const access = await getProjectAccessCached({
|
||||
|
||||
Reference in New Issue
Block a user