feature(dashboard): improved funnels
This commit is contained in:
committed by
GitHub
parent
bb018d55ca
commit
1257381bf2
@@ -30,45 +30,42 @@ const OverviewTopBots = ({ projectId }: Props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="-m-4">
|
<WidgetTable
|
||||||
<WidgetTable
|
className="max-w-full [&_td:first-child]:w-full [&_th]:text-sm [&_tr]:text-sm"
|
||||||
className="max-w-full [&_td:first-child]:w-full [&_th]:text-sm [&_tr]:text-sm"
|
data={data}
|
||||||
data={data}
|
keyExtractor={(item) => item.id}
|
||||||
keyExtractor={(item) => item.id}
|
columns={[
|
||||||
columns={[
|
{
|
||||||
{
|
name: 'Path',
|
||||||
name: 'Path',
|
render(item) {
|
||||||
render(item) {
|
return (
|
||||||
return (
|
<Tooltiper asChild content={item.path}>
|
||||||
<Tooltiper asChild content={item.path}>
|
<span className="w-full">{getPath(item.path)}</span>
|
||||||
<span className="w-full">{getPath(item.path)}</span>
|
</Tooltiper>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Date',
|
||||||
|
render(item) {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-2 whitespace-nowrap">
|
||||||
|
<Tooltiper asChild content={`${item.type}`}>
|
||||||
|
<div>{item.name}</div>
|
||||||
</Tooltiper>
|
</Tooltiper>
|
||||||
);
|
<Tooltiper
|
||||||
},
|
asChild
|
||||||
|
content={`${item.createdAt.toLocaleString()}`}
|
||||||
|
>
|
||||||
|
<div>{item.createdAt.toLocaleDateString()}</div>
|
||||||
|
</Tooltiper>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
},
|
},
|
||||||
{
|
},
|
||||||
name: 'Date',
|
]}
|
||||||
render(item) {
|
/>
|
||||||
return (
|
|
||||||
<div className="flex gap-2 whitespace-nowrap">
|
|
||||||
<Tooltiper asChild content={`${item.type}`}>
|
|
||||||
<div>{item.name}</div>
|
|
||||||
</Tooltiper>
|
|
||||||
<Tooltiper
|
|
||||||
asChild
|
|
||||||
content={`${item.createdAt.toLocaleString()}`}
|
|
||||||
>
|
|
||||||
<div>{item.createdAt.toLocaleDateString()}</div>
|
|
||||||
</Tooltiper>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Pagination
|
<Pagination
|
||||||
className="mt-4"
|
|
||||||
cursor={cursor}
|
cursor={cursor}
|
||||||
setCursor={setCursor}
|
setCursor={setCursor}
|
||||||
count={count}
|
count={count}
|
||||||
|
|||||||
@@ -340,12 +340,11 @@ export default function OverviewTopDevices({
|
|||||||
))}
|
))}
|
||||||
</WidgetButtons>
|
</WidgetButtons>
|
||||||
</WidgetHead>
|
</WidgetHead>
|
||||||
<WidgetBody>
|
<WidgetBody className="p-0">
|
||||||
{query.isLoading ? (
|
{query.isLoading ? (
|
||||||
<OverviewWidgetTableLoading className="-m-4" />
|
<OverviewWidgetTableLoading />
|
||||||
) : (
|
) : (
|
||||||
<OverviewWidgetTableGeneric
|
<OverviewWidgetTableGeneric
|
||||||
className="-m-4"
|
|
||||||
data={query.data ?? []}
|
data={query.data ?? []}
|
||||||
column={{
|
column={{
|
||||||
name: OVERVIEW_COLUMNS_NAME[widget.key],
|
name: OVERVIEW_COLUMNS_NAME[widget.key],
|
||||||
|
|||||||
@@ -79,12 +79,11 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
|
|||||||
))}
|
))}
|
||||||
</WidgetButtons>
|
</WidgetButtons>
|
||||||
</WidgetHead>
|
</WidgetHead>
|
||||||
<WidgetBody>
|
<WidgetBody className="p-0">
|
||||||
{query.isLoading ? (
|
{query.isLoading ? (
|
||||||
<OverviewWidgetTableLoading className="-m-4" />
|
<OverviewWidgetTableLoading />
|
||||||
) : (
|
) : (
|
||||||
<OverviewWidgetTableGeneric
|
<OverviewWidgetTableGeneric
|
||||||
className="-m-4"
|
|
||||||
data={query.data ?? []}
|
data={query.data ?? []}
|
||||||
column={{
|
column={{
|
||||||
name: OVERVIEW_COLUMNS_NAME[widget.key],
|
name: OVERVIEW_COLUMNS_NAME[widget.key],
|
||||||
|
|||||||
@@ -95,14 +95,13 @@ export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) {
|
|||||||
))}
|
))}
|
||||||
</WidgetButtons>
|
</WidgetButtons>
|
||||||
</WidgetHead>
|
</WidgetHead>
|
||||||
<WidgetBody>
|
<WidgetBody className="p-0">
|
||||||
{query.isLoading ? (
|
{query.isLoading ? (
|
||||||
<OverviewWidgetTableLoading className="-m-4" />
|
<OverviewWidgetTableLoading />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/*<OverviewWidgetTableBots className="-m-4" data={data ?? []} />*/}
|
{/*<OverviewWidgetTableBots data={data ?? []} />*/}
|
||||||
<OverviewWidgetTablePages
|
<OverviewWidgetTablePages
|
||||||
className="-m-4"
|
|
||||||
data={data ?? []}
|
data={data ?? []}
|
||||||
lastColumnName={widget.meta.columns.sessions}
|
lastColumnName={widget.meta.columns.sessions}
|
||||||
showDomain={!!domain}
|
showDomain={!!domain}
|
||||||
|
|||||||
@@ -90,12 +90,11 @@ export default function OverviewTopSources({
|
|||||||
))}
|
))}
|
||||||
</WidgetButtons>
|
</WidgetButtons>
|
||||||
</WidgetHead>
|
</WidgetHead>
|
||||||
<WidgetBody>
|
<WidgetBody className="p-0">
|
||||||
{query.isLoading ? (
|
{query.isLoading ? (
|
||||||
<OverviewWidgetTableLoading className="-m-4" />
|
<OverviewWidgetTableLoading />
|
||||||
) : (
|
) : (
|
||||||
<OverviewWidgetTableGeneric
|
<OverviewWidgetTableGeneric
|
||||||
className="-m-4"
|
|
||||||
data={query.data ?? []}
|
data={query.data ?? []}
|
||||||
column={{
|
column={{
|
||||||
name: OVERVIEW_COLUMNS_NAME[widget.key],
|
name: OVERVIEW_COLUMNS_NAME[widget.key],
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export const OverviewWidgetTable = <T,>({
|
|||||||
data={data ?? []}
|
data={data ?? []}
|
||||||
keyExtractor={keyExtractor}
|
keyExtractor={keyExtractor}
|
||||||
className={'text-sm min-h-[358px] @container'}
|
className={'text-sm min-h-[358px] @container'}
|
||||||
columnClassName="px-2 group/row items-center"
|
columnClassName="group/row [&>*:first-child]:pl-4 [&>*:last-child]:pr-4 [&_th]:pt-3"
|
||||||
eachRow={(item) => {
|
eachRow={(item) => {
|
||||||
return (
|
return (
|
||||||
<div className="absolute inset-0 !p-0">
|
<div className="absolute inset-0 !p-0">
|
||||||
@@ -43,11 +43,11 @@ export const OverviewWidgetTable = <T,>({
|
|||||||
...column,
|
...column,
|
||||||
className: cn(
|
className: cn(
|
||||||
index === 0
|
index === 0
|
||||||
? 'w-full flex-1 font-medium min-w-0'
|
? 'text-left w-full font-medium min-w-0'
|
||||||
: 'text-right justify-end row w-20 font-mono',
|
: 'text-right w-20 font-mono',
|
||||||
index !== 0 &&
|
index !== 0 &&
|
||||||
index !== columns.length - 1 &&
|
index !== columns.length - 1 &&
|
||||||
'hidden @[310px]:row',
|
'hidden @[310px]:table-cell',
|
||||||
column.className,
|
column.className,
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,257 +1,390 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { ColorSquare } from '@/components/color-square';
|
import { ColorSquare } from '@/components/color-square';
|
||||||
import { TooltipComplete } from '@/components/tooltip-complete';
|
|
||||||
import { Progress } from '@/components/ui/progress';
|
|
||||||
import type { RouterOutputs } from '@/trpc/client';
|
import type { RouterOutputs } from '@/trpc/client';
|
||||||
import { cn } from '@/utils/cn';
|
import { cn } from '@/utils/cn';
|
||||||
import { AlertCircleIcon } from 'lucide-react';
|
import { ChevronRightIcon, InfoIcon } from 'lucide-react';
|
||||||
import { last } from 'ramda';
|
|
||||||
|
|
||||||
import { getPreviousMetric, round } from '@openpanel/common';
|
|
||||||
import { alphabetIds } from '@openpanel/constants';
|
import { alphabetIds } from '@openpanel/constants';
|
||||||
|
|
||||||
|
import { createChartTooltip } from '@/components/charts/chart-tooltip';
|
||||||
|
import { Tooltiper } from '@/components/ui/tooltip';
|
||||||
|
import { WidgetTable } from '@/components/widget-table';
|
||||||
import { useNumber } from '@/hooks/useNumerFormatter';
|
import { useNumber } from '@/hooks/useNumerFormatter';
|
||||||
import { PreviousDiffIndicator } from '../common/previous-diff-indicator';
|
import { getChartColor } from '@/utils/theme';
|
||||||
import { useReportChartContext } from '../context';
|
import { getPreviousMetric } from '@openpanel/common';
|
||||||
import { MetricCardNumber } from '../metric/metric-card';
|
import {
|
||||||
|
CartesianGrid,
|
||||||
const findMostDropoffs = (
|
Line,
|
||||||
steps: RouterOutputs['chart']['funnel']['current']['steps'],
|
LineChart,
|
||||||
) => {
|
ResponsiveContainer,
|
||||||
return steps.reduce((acc, step) => {
|
XAxis,
|
||||||
if (step.dropoffCount > acc.dropoffCount) {
|
YAxis,
|
||||||
return step;
|
} from 'recharts';
|
||||||
}
|
import { useXAxisProps, useYAxisProps } from '../common/axis';
|
||||||
return acc;
|
import { PreviousDiffIndicatorPure } from '../common/previous-diff-indicator';
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
data: RouterOutputs['chart']['funnel'];
|
data: {
|
||||||
|
current: RouterOutputs['chart']['funnel']['current'][number];
|
||||||
|
previous: RouterOutputs['chart']['funnel']['current'][number] | null;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export function Chart({
|
const Metric = ({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
enhancer,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: React.ReactNode;
|
||||||
|
enhancer?: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) => (
|
||||||
|
<div className={cn('gap-1 justify-between flex-1 col', className)}>
|
||||||
|
<div className="text-sm text-muted-foreground">{label}</div>
|
||||||
|
<div className="row items-center gap-2 justify-between">
|
||||||
|
<div className="font-mono font-semibold">{value}</div>
|
||||||
|
{enhancer && <div>{enhancer}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export function Summary({ data }: { data: RouterOutputs['chart']['funnel'] }) {
|
||||||
|
const number = useNumber();
|
||||||
|
const highestConversion = data.current
|
||||||
|
.slice(0)
|
||||||
|
.sort((a, b) => b.lastStep.percent - a.lastStep.percent)[0];
|
||||||
|
const highestCount = data.current
|
||||||
|
.slice(0)
|
||||||
|
.sort((a, b) => b.lastStep.count - a.lastStep.count)[0];
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{highestConversion && (
|
||||||
|
<div className="card row items-center p-4 py-3">
|
||||||
|
<Metric
|
||||||
|
label="Highest conversion rate"
|
||||||
|
value={
|
||||||
|
<ChartName breakdowns={highestConversion.breakdowns ?? []} />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className="text-xl font-semibold font-mono">
|
||||||
|
{number.formatWithUnit(
|
||||||
|
highestConversion.lastStep.percent / 100,
|
||||||
|
'%',
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{highestCount && (
|
||||||
|
<div className="card row items-center p-4 py-3">
|
||||||
|
<Metric
|
||||||
|
label="Most conversions"
|
||||||
|
value={<ChartName breakdowns={highestCount.breakdowns ?? []} />}
|
||||||
|
/>
|
||||||
|
<span className="text-xl font-semibold font-mono">
|
||||||
|
{number.format(highestCount.lastStep.count)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChartName({
|
||||||
|
breakdowns,
|
||||||
|
className,
|
||||||
|
}: { breakdowns: string[]; className?: string }) {
|
||||||
|
return (
|
||||||
|
<div className={cn('flex items-center gap-2 font-medium', className)}>
|
||||||
|
{breakdowns.map((name, index) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{index !== 0 && <ChevronRightIcon className="size-3" />}
|
||||||
|
<span key={name}>{name}</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Tables({
|
||||||
data: {
|
data: {
|
||||||
current: { steps, totalSessions },
|
current: { steps, mostDropoffsStep, lastStep, breakdowns },
|
||||||
previous,
|
previous,
|
||||||
},
|
},
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const number = useNumber();
|
const number = useNumber();
|
||||||
const { isEditMode } = useReportChartContext();
|
const hasHeader = breakdowns.length > 0;
|
||||||
const mostDropoffs = findMostDropoffs(steps);
|
|
||||||
const lastStep = last(steps)!;
|
|
||||||
const prevLastStep = previous?.steps ? last(previous.steps) : null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('col gap-4 @container', isEditMode ? 'card' : '-m-4')}>
|
<div className={cn('col @container divide-y divide-border card')}>
|
||||||
<div
|
{hasHeader && <ChartName breakdowns={breakdowns} className="p-4 py-3" />}
|
||||||
className={cn(
|
<div className={cn('bg-def-100', !hasHeader && 'rounded-t-md')}>
|
||||||
'border-b border-border bg-def-100',
|
<div className="col max-md:divide-y md:row md:items-center md:divide-x divide-border">
|
||||||
isEditMode && 'rounded-t-md',
|
<Metric
|
||||||
)}
|
className="p-4 py-3"
|
||||||
>
|
label="Conversion"
|
||||||
<div className="flex items-center gap-8 p-4 px-8">
|
value={number.formatWithUnit(lastStep?.percent / 100, '%')}
|
||||||
<MetricCardNumber
|
|
||||||
className="flex-1"
|
|
||||||
label="Converted"
|
|
||||||
value={lastStep.count}
|
|
||||||
enhancer={
|
enhancer={
|
||||||
<PreviousDiffIndicator
|
previous && (
|
||||||
size="md"
|
<PreviousDiffIndicatorPure
|
||||||
{...getPreviousMetric(lastStep.count, prevLastStep?.count)}
|
{...getPreviousMetric(
|
||||||
/>
|
lastStep?.percent,
|
||||||
|
previous.lastStep?.percent,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<MetricCardNumber
|
<Metric
|
||||||
className="flex-1"
|
className="p-4 py-3"
|
||||||
label="Percent"
|
label="Completed"
|
||||||
value={`${totalSessions ? round((lastStep.count / totalSessions) * 100, 2) : 0}%`}
|
value={number.format(lastStep?.count)}
|
||||||
enhancer={
|
enhancer={
|
||||||
<PreviousDiffIndicator
|
previous && (
|
||||||
size="md"
|
<PreviousDiffIndicatorPure
|
||||||
{...getPreviousMetric(lastStep.count, prevLastStep?.count)}
|
{...getPreviousMetric(
|
||||||
/>
|
lastStep?.count,
|
||||||
}
|
previous.lastStep?.count,
|
||||||
/>
|
)}
|
||||||
<MetricCardNumber
|
/>
|
||||||
className="flex-1"
|
)
|
||||||
label="Most dropoffs"
|
|
||||||
value={mostDropoffs.event.displayName}
|
|
||||||
enhancer={
|
|
||||||
<PreviousDiffIndicator
|
|
||||||
size="md"
|
|
||||||
{...getPreviousMetric(lastStep.count, prevLastStep?.count)}
|
|
||||||
/>
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
{!!mostDropoffsStep && (
|
||||||
|
<Metric
|
||||||
|
className="p-4 py-3"
|
||||||
|
label="Most dropoffs after"
|
||||||
|
value={mostDropoffsStep?.event?.displayName}
|
||||||
|
enhancer={
|
||||||
|
<Tooltiper
|
||||||
|
tooltipClassName="max-w-xs"
|
||||||
|
content={
|
||||||
|
<span>
|
||||||
|
<span className="font-semibold">
|
||||||
|
{mostDropoffsStep?.dropoffCount}
|
||||||
|
</span>{' '}
|
||||||
|
dropped after this event. Improve this step and your
|
||||||
|
conversion rate will likely increase.
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<InfoIcon className="size-3" />
|
||||||
|
</Tooltiper>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="col divide-y divide-def-200">
|
<div className="col divide-y divide-def-200">
|
||||||
{steps.map((step, index) => {
|
<WidgetTable
|
||||||
const percent = (step.count / totalSessions) * 100;
|
data={steps}
|
||||||
const isMostDropoffs = mostDropoffs.event.id === step.event.id;
|
keyExtractor={(item) => item.event.id!}
|
||||||
return (
|
className={'text-sm @container'}
|
||||||
<div
|
columnClassName="px-2 group/row items-center"
|
||||||
key={step.event.id}
|
eachRow={(item, index) => {
|
||||||
className="col gap-12 px-4 py-4 @2xl:flex-row @2xl:px-8"
|
return (
|
||||||
>
|
<div className="absolute inset-px !p-0">
|
||||||
<div className="relative flex flex-1 flex-col gap-2 pl-8">
|
<div
|
||||||
<ColorSquare className="absolute left-0 top-0.5">
|
className={cn(
|
||||||
{alphabetIds[index]}
|
'h-full bg-def-300 group-hover/row:bg-blue-200 dark:group-hover/row:bg-blue-900 transition-colors relative',
|
||||||
</ColorSquare>
|
item.isHighestDropoff && [
|
||||||
<div className="font-semibold mt-1">
|
'bg-red-500/20',
|
||||||
{step.event.displayName}
|
'group-hover/row:bg-red-500/70',
|
||||||
</div>
|
],
|
||||||
<div className="grid grid-cols-4 max-w-lg gap-8 text-sm">
|
index === steps.length - 1 && 'rounded-bl-sm',
|
||||||
<TooltipComplete
|
)}
|
||||||
disabled={!previous?.steps?.[index]}
|
style={{
|
||||||
content={
|
width: `${item.percent}%`,
|
||||||
<div className="flex items-center gap-2">
|
}}
|
||||||
<span>
|
/>
|
||||||
Last period:{' '}
|
|
||||||
<span className="font-mono">
|
|
||||||
{number.format(
|
|
||||||
previous?.steps?.[index]?.previousCount,
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<PreviousDiffIndicator
|
|
||||||
{...getPreviousMetric(
|
|
||||||
step.previousCount,
|
|
||||||
previous?.steps?.[index]?.previousCount,
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="col gap-2">
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
Total:
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<span className="text-lg font-mono">
|
|
||||||
{number.format(step.previousCount)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TooltipComplete>
|
|
||||||
<TooltipComplete
|
|
||||||
disabled={!previous?.steps?.[index]}
|
|
||||||
content={
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span>
|
|
||||||
Last period:{' '}
|
|
||||||
<span className="font-mono">
|
|
||||||
{number.format(
|
|
||||||
previous?.steps?.[index]?.dropoffCount,
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<PreviousDiffIndicator
|
|
||||||
inverted
|
|
||||||
{...getPreviousMetric(
|
|
||||||
step.dropoffCount,
|
|
||||||
previous?.steps?.[index]?.dropoffCount,
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="col gap-2">
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
Dropoff:
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
'flex items-center gap-1 text-lg font-mono',
|
|
||||||
isMostDropoffs && 'text-rose-500',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{isMostDropoffs && <AlertCircleIcon size={14} />}
|
|
||||||
{number.format(step.dropoffCount)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TooltipComplete>
|
|
||||||
<TooltipComplete
|
|
||||||
disabled={!previous?.steps?.[index]}
|
|
||||||
content={
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span>
|
|
||||||
Last period:{' '}
|
|
||||||
<span className="font-mono">
|
|
||||||
{number.format(previous?.steps?.[index]?.count)}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<PreviousDiffIndicator
|
|
||||||
{...getPreviousMetric(
|
|
||||||
step.count,
|
|
||||||
previous?.steps?.[index]?.count,
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="col gap-2">
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
Current:
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<span className="text-lg font-mono">
|
|
||||||
{number.format(step.count)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TooltipComplete>
|
|
||||||
<TooltipComplete
|
|
||||||
disabled={!previous?.steps?.[index]}
|
|
||||||
content={
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span>
|
|
||||||
Last period:{' '}
|
|
||||||
<span className="font-mono">
|
|
||||||
{number.format(previous?.steps?.[index]?.count)}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<PreviousDiffIndicator
|
|
||||||
{...getPreviousMetric(
|
|
||||||
step.count,
|
|
||||||
previous?.steps?.[index]?.count,
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="col gap-2">
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
Percent:
|
|
||||||
</span>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<span className="text-lg font-mono">
|
|
||||||
{Number.isNaN(percent) ? 0 : round(percent, 2)}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TooltipComplete>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<Progress
|
);
|
||||||
size="lg"
|
}}
|
||||||
className={cn(
|
columns={[
|
||||||
'w-full @2xl:w-1/4 text-white bg-def-200 mt-0.5 dark:text-black',
|
{
|
||||||
)}
|
name: 'Event',
|
||||||
innerClassName={cn(
|
render: (item, index) => (
|
||||||
'bg-primary',
|
<div className="row items-center gap-2 row min-w-0 relative">
|
||||||
step.event.id === mostDropoffs.event.id && 'bg-rose-500',
|
<ColorSquare>{alphabetIds[index]}</ColorSquare>
|
||||||
)}
|
<span className="truncate">{item.event.displayName}</span>
|
||||||
value={percent}
|
</div>
|
||||||
/>
|
),
|
||||||
</div>
|
className: 'text-left font-mono font-semibold',
|
||||||
);
|
},
|
||||||
})}
|
{
|
||||||
|
name: 'Completed',
|
||||||
|
render: (item) => number.format(item.count),
|
||||||
|
className: 'text-right font-mono',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Dropped after',
|
||||||
|
render: (item) =>
|
||||||
|
item.dropoffCount !== null && item.dropoffPercent !== null
|
||||||
|
? number.format(item.dropoffCount)
|
||||||
|
: null,
|
||||||
|
className: 'text-right font-mono',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Conversion',
|
||||||
|
render: (item) => number.formatWithUnit(item.percent / 100, '%'),
|
||||||
|
className: 'text-right font-mono font-semibold',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RechartData = {
|
||||||
|
name: string;
|
||||||
|
[key: `step:percent:${number}`]: number | null;
|
||||||
|
[key: `step:data:${number}`]:
|
||||||
|
| (RouterOutputs['chart']['funnel']['current'][number] & {
|
||||||
|
step: RouterOutputs['chart']['funnel']['current'][number]['steps'][number];
|
||||||
|
})
|
||||||
|
| null;
|
||||||
|
[key: `prev_step:percent:${number}`]: number | null;
|
||||||
|
[key: `prev_step:data:${number}`]:
|
||||||
|
| (RouterOutputs['chart']['funnel']['current'][number] & {
|
||||||
|
step: RouterOutputs['chart']['funnel']['current'][number]['steps'][number];
|
||||||
|
})
|
||||||
|
| null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const useRechartData = ({
|
||||||
|
current,
|
||||||
|
previous,
|
||||||
|
}: RouterOutputs['chart']['funnel']): RechartData[] => {
|
||||||
|
const firstFunnel = current[0];
|
||||||
|
return (
|
||||||
|
firstFunnel?.steps.map((step, stepIndex) => {
|
||||||
|
return {
|
||||||
|
name: step?.event.displayName ?? '',
|
||||||
|
...current.reduce((acc, item, index) => {
|
||||||
|
const diff = previous?.[index];
|
||||||
|
return {
|
||||||
|
...acc,
|
||||||
|
[`step:percent:${index}`]: item.steps[stepIndex]?.percent ?? null,
|
||||||
|
[`step:data:${index}`]:
|
||||||
|
{
|
||||||
|
...item,
|
||||||
|
step: item.steps[stepIndex],
|
||||||
|
} ?? null,
|
||||||
|
[`prev_step:percent:${index}`]:
|
||||||
|
diff?.steps[stepIndex]?.percent ?? null,
|
||||||
|
[`prev_step:data:${index}`]: diff
|
||||||
|
? {
|
||||||
|
...diff,
|
||||||
|
step: diff?.steps?.[stepIndex],
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}, {}),
|
||||||
|
};
|
||||||
|
}) ?? []
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Chart({ data }: { data: RouterOutputs['chart']['funnel'] }) {
|
||||||
|
const rechartData = useRechartData(data);
|
||||||
|
const xAxisProps = useXAxisProps();
|
||||||
|
const yAxisProps = useYAxisProps();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipProvider data={data.current}>
|
||||||
|
<div className="aspect-video max-h-[100px] w-full p-4 card pb-1">
|
||||||
|
<ResponsiveContainer>
|
||||||
|
<LineChart data={rechartData}>
|
||||||
|
<CartesianGrid
|
||||||
|
strokeDasharray="3 3"
|
||||||
|
horizontal={true}
|
||||||
|
vertical={true}
|
||||||
|
className="stroke-border"
|
||||||
|
/>
|
||||||
|
<XAxis
|
||||||
|
{...xAxisProps}
|
||||||
|
dataKey="name"
|
||||||
|
allowDuplicatedCategory={false}
|
||||||
|
type={'category'}
|
||||||
|
scale="auto"
|
||||||
|
domain={undefined}
|
||||||
|
interval="preserveStartEnd"
|
||||||
|
tickSize={0}
|
||||||
|
tickMargin={4}
|
||||||
|
/>
|
||||||
|
<YAxis {...yAxisProps} />
|
||||||
|
{data.current.map((item, index) => (
|
||||||
|
<Line
|
||||||
|
stroke={getChartColor(index)}
|
||||||
|
key={`step:percent:${item.id}`}
|
||||||
|
dataKey={`step:percent:${index}`}
|
||||||
|
type="linear"
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<Tooltip />
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { Tooltip, TooltipProvider } = createChartTooltip<
|
||||||
|
RechartData,
|
||||||
|
Record<string, unknown>
|
||||||
|
>(({ data }) => {
|
||||||
|
const number = useNumber();
|
||||||
|
const variants = Object.keys(data).filter((key) =>
|
||||||
|
key.startsWith('step:data:'),
|
||||||
|
) as `step:data:${number}`[];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex justify-between gap-8 text-muted-foreground">
|
||||||
|
<div>{data.name}</div>
|
||||||
|
</div>
|
||||||
|
{variants.map((key, index) => {
|
||||||
|
const variant = data[key];
|
||||||
|
const prevVariant = data[`prev_${key}`];
|
||||||
|
if (!variant?.step) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="row gap-2" key={key}>
|
||||||
|
<div
|
||||||
|
className="w-[3px] rounded-full"
|
||||||
|
style={{ background: getChartColor(index) }}
|
||||||
|
/>
|
||||||
|
<div className="col flex-1 gap-1">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<ChartName breakdowns={variant.breakdowns ?? []} />
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between gap-8 font-mono font-medium">
|
||||||
|
<div className="col gap-1">
|
||||||
|
<span>
|
||||||
|
{number.formatWithUnit(variant.step.percent / 100, '%')}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
({number.format(variant.step.count)})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PreviousDiffIndicatorPure
|
||||||
|
{...getPreviousMetric(
|
||||||
|
variant.step.percent,
|
||||||
|
prevVariant?.step.percent,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { api } from '@/trpc/client';
|
import { type RouterOutputs, api } from '@/trpc/client';
|
||||||
|
|
||||||
import type { IChartInput } from '@openpanel/validation';
|
import type { IChartInput } from '@openpanel/validation';
|
||||||
|
|
||||||
@@ -9,7 +9,7 @@ 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 } from './chart';
|
import { Chart, Summary, Tables } from './chart';
|
||||||
|
|
||||||
export function ReportFunnelChart() {
|
export function ReportFunnelChart() {
|
||||||
const {
|
const {
|
||||||
@@ -22,6 +22,7 @@ export function ReportFunnelChart() {
|
|||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
previous,
|
previous,
|
||||||
|
breakdowns,
|
||||||
},
|
},
|
||||||
isLazyLoading,
|
isLazyLoading,
|
||||||
} = useReportChartContext();
|
} = useReportChartContext();
|
||||||
@@ -32,7 +33,7 @@ export function ReportFunnelChart() {
|
|||||||
projectId,
|
projectId,
|
||||||
interval: 'day',
|
interval: 'day',
|
||||||
chartType: 'funnel',
|
chartType: 'funnel',
|
||||||
breakdowns: [],
|
breakdowns,
|
||||||
funnelWindow,
|
funnelWindow,
|
||||||
funnelGroup,
|
funnelGroup,
|
||||||
previous,
|
previous,
|
||||||
@@ -41,7 +42,6 @@ export function ReportFunnelChart() {
|
|||||||
endDate,
|
endDate,
|
||||||
};
|
};
|
||||||
const res = api.chart.funnel.useQuery(input, {
|
const res = api.chart.funnel.useQuery(input, {
|
||||||
keepPreviousData: true,
|
|
||||||
enabled: !isLazyLoading,
|
enabled: !isLazyLoading,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -53,11 +53,25 @@ export function ReportFunnelChart() {
|
|||||||
return <Error />;
|
return <Error />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (res.data.current.steps.length === 0) {
|
if (res.data.current.length === 0) {
|
||||||
return <Empty />;
|
return <Empty />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <Chart data={res.data} />;
|
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,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Loading() {
|
function Loading() {
|
||||||
|
|||||||
@@ -10,14 +10,14 @@ import { ReportSettings } from './ReportSettings';
|
|||||||
export function ReportSidebar() {
|
export function ReportSidebar() {
|
||||||
const { chartType } = useSelector((state) => state.report);
|
const { chartType } = useSelector((state) => state.report);
|
||||||
const showFormula = chartType !== 'funnel' && chartType !== 'retention';
|
const showFormula = chartType !== 'funnel' && chartType !== 'retention';
|
||||||
const showBreakdown = chartType !== 'funnel' && chartType !== 'retention';
|
const showBreakdown = chartType !== 'retention';
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col gap-8">
|
<div className="flex flex-col gap-8">
|
||||||
<ReportEvents />
|
<ReportEvents />
|
||||||
<ReportSettings />
|
|
||||||
{showFormula && <ReportFormula />}
|
|
||||||
{showBreakdown && <ReportBreakdowns />}
|
{showBreakdown && <ReportBreakdowns />}
|
||||||
|
{showFormula && <ReportFormula />}
|
||||||
|
<ReportSettings />
|
||||||
</div>
|
</div>
|
||||||
<SheetFooter>
|
<SheetFooter>
|
||||||
<SheetClose asChild>
|
<SheetClose asChild>
|
||||||
|
|||||||
@@ -2,14 +2,14 @@ import { cn } from '@/utils/cn';
|
|||||||
|
|
||||||
export interface Props<T> {
|
export interface Props<T> {
|
||||||
columns: {
|
columns: {
|
||||||
name: string;
|
name: React.ReactNode;
|
||||||
render: (item: T) => React.ReactNode;
|
render: (item: T, index: number) => React.ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
}[];
|
}[];
|
||||||
keyExtractor: (item: T) => string;
|
keyExtractor: (item: T) => string;
|
||||||
data: T[];
|
data: T[];
|
||||||
className?: string;
|
className?: string;
|
||||||
eachRow?: (item: T) => React.ReactNode;
|
eachRow?: (item: T, index: number) => React.ReactNode;
|
||||||
columnClassName?: string;
|
columnClassName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,62 +43,55 @@ export function WidgetTable<T>({
|
|||||||
return (
|
return (
|
||||||
<div className="w-full overflow-x-auto">
|
<div className="w-full overflow-x-auto">
|
||||||
<div className={cn('w-full', className)}>
|
<div className={cn('w-full', className)}>
|
||||||
<div
|
<table className="w-full table-fixed">
|
||||||
className={cn(
|
<thead>
|
||||||
'border-b border-border text-right last:border-0 [&_div:first-child]:text-left grid',
|
<tr
|
||||||
'[&>div]:p-2',
|
|
||||||
columnClassName,
|
|
||||||
)}
|
|
||||||
style={{
|
|
||||||
gridTemplateColumns:
|
|
||||||
columns.length > 1
|
|
||||||
? `1fr ${columns
|
|
||||||
.slice(1)
|
|
||||||
.map((col) => 'auto')
|
|
||||||
.join(' ')}`
|
|
||||||
: '1fr',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{columns.map((column) => (
|
|
||||||
<div
|
|
||||||
key={column.name}
|
|
||||||
className={cn(column.className, 'font-medium font-sans text-sm')}
|
|
||||||
>
|
|
||||||
{column.name}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="col">
|
|
||||||
{data.map((item) => (
|
|
||||||
<div
|
|
||||||
key={keyExtractor(item)}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'border-b border-border text-right last:border-0 [&_div:first-child]:text-left grid relative',
|
'border-b border-border text-right last:border-0 [&_td:first-child]:text-left',
|
||||||
'[&>div]:p-2',
|
'[&>td]:p-2',
|
||||||
columnClassName,
|
columnClassName,
|
||||||
)}
|
)}
|
||||||
style={{
|
|
||||||
gridTemplateColumns:
|
|
||||||
columns.length > 1
|
|
||||||
? `1fr ${columns
|
|
||||||
.slice(1)
|
|
||||||
.map((col) => 'auto')
|
|
||||||
.join(' ')}`
|
|
||||||
: '1fr',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{eachRow?.(item)}
|
|
||||||
{columns.map((column) => (
|
{columns.map((column) => (
|
||||||
<div
|
<th
|
||||||
key={column.name}
|
key={column.name?.toString()}
|
||||||
className={cn(column.className, 'relative h-8')}
|
className={cn(
|
||||||
|
column.className,
|
||||||
|
'font-medium font-sans text-sm p-2 whitespace-nowrap',
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{column.render(item)}
|
{column.name}
|
||||||
</div>
|
</th>
|
||||||
))}
|
))}
|
||||||
</div>
|
</tr>
|
||||||
))}
|
</thead>
|
||||||
</div>
|
<tbody>
|
||||||
|
{data.map((item, index) => (
|
||||||
|
<tr
|
||||||
|
key={keyExtractor(item)}
|
||||||
|
className={cn(
|
||||||
|
'h-8 border-b border-border text-right last:border-0 [&_td:first-child]:text-left relative',
|
||||||
|
'[&>td]:p-2',
|
||||||
|
columnClassName,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{columns.map((column, columnIndex) => (
|
||||||
|
<td
|
||||||
|
key={column.name?.toString()}
|
||||||
|
className={cn(
|
||||||
|
'h-8',
|
||||||
|
columnIndex !== 0 && 'relative z-5',
|
||||||
|
column.className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{columnIndex === 0 && eachRow?.(item, index)}
|
||||||
|
{column.render(item, index)}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export * from './src/services/reports.service';
|
|||||||
export * from './src/services/salt.service';
|
export * from './src/services/salt.service';
|
||||||
export * from './src/services/share.service';
|
export * from './src/services/share.service';
|
||||||
export * from './src/services/session.service';
|
export * from './src/services/session.service';
|
||||||
|
export * from './src/services/funnel.service';
|
||||||
export * from './src/services/user.service';
|
export * from './src/services/user.service';
|
||||||
export * from './src/services/reference.service';
|
export * from './src/services/reference.service';
|
||||||
export * from './src/services/id.service';
|
export * from './src/services/id.service';
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ export class Query<T = any> {
|
|||||||
if (!Array.isArray(value) && !(value instanceof Expression)) {
|
if (!Array.isArray(value) && !(value instanceof Expression)) {
|
||||||
throw new Error(`${operator} operator requires an array value`);
|
throw new Error(`${operator} operator requires an array value`);
|
||||||
}
|
}
|
||||||
return `${column} ${operator} (${this.escapeValue(value)})`;
|
return `${column} ${operator} ${this.escapeValue(value)}`;
|
||||||
default:
|
default:
|
||||||
return `${column} ${operator} ${this.escapeValue(value!)}`;
|
return `${column} ${operator} ${this.escapeValue(value!)}`;
|
||||||
}
|
}
|
||||||
|
|||||||
265
packages/db/src/services/funnel.service.ts
Normal file
265
packages/db/src/services/funnel.service.ts
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
import type { IChartEvent, IChartInput } from '@openpanel/validation';
|
||||||
|
import { last, reverse } from 'ramda';
|
||||||
|
import { escape } from 'sqlstring';
|
||||||
|
import { ch } from '../clickhouse/client';
|
||||||
|
import { TABLE_NAMES } from '../clickhouse/client';
|
||||||
|
import { clix } from '../clickhouse/query-builder';
|
||||||
|
import { createSqlBuilder } from '../sql-builder';
|
||||||
|
import {
|
||||||
|
getEventFiltersWhereClause,
|
||||||
|
getSelectPropertyKey,
|
||||||
|
} from './chart.service';
|
||||||
|
|
||||||
|
export class FunnelService {
|
||||||
|
constructor(private client: typeof ch) {}
|
||||||
|
|
||||||
|
private getFunnelGroup(group?: string) {
|
||||||
|
return group === 'profile_id'
|
||||||
|
? [`COALESCE(nullIf(s.profile_id, ''), e.profile_id)`, 'profile_id']
|
||||||
|
: ['session_id', 'session_id'];
|
||||||
|
}
|
||||||
|
|
||||||
|
private getFunnelConditions(events: IChartEvent[]) {
|
||||||
|
return events.map((event) => {
|
||||||
|
const { sb, getWhere } = createSqlBuilder();
|
||||||
|
sb.where = getEventFiltersWhereClause(event.filters);
|
||||||
|
sb.where.name = `name = ${escape(event.name)}`;
|
||||||
|
return getWhere().replace('WHERE ', '');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private fillFunnel(
|
||||||
|
funnel: { level: number; count: number }[],
|
||||||
|
steps: number,
|
||||||
|
) {
|
||||||
|
const filled = Array.from({ length: steps }, (_, index) => {
|
||||||
|
const level = index + 1;
|
||||||
|
const matchingResult = funnel.find((res) => res.level === level);
|
||||||
|
return {
|
||||||
|
level,
|
||||||
|
count: matchingResult ? matchingResult.count : 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Accumulate counts from top to bottom of the funnel
|
||||||
|
for (let i = filled.length - 1; i >= 0; i--) {
|
||||||
|
const step = filled[i];
|
||||||
|
const prevStep = filled[i + 1];
|
||||||
|
// If there's a previous step, add the count to the current step
|
||||||
|
if (step && prevStep) {
|
||||||
|
step.count += prevStep.count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filled.reverse();
|
||||||
|
}
|
||||||
|
|
||||||
|
toSeries(
|
||||||
|
funnel: { level: number; count: number; [key: string]: any }[],
|
||||||
|
breakdowns: { name: string }[] = [],
|
||||||
|
) {
|
||||||
|
if (!breakdowns.length) {
|
||||||
|
return [
|
||||||
|
funnel.map((f) => ({
|
||||||
|
level: f.level,
|
||||||
|
count: f.count,
|
||||||
|
id: 'none',
|
||||||
|
breakdowns: [],
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group by breakdown values
|
||||||
|
const series = funnel.reduce(
|
||||||
|
(acc, f) => {
|
||||||
|
const key = breakdowns.map((b, index) => f[`b_${index}`]).join('|');
|
||||||
|
if (!acc[key]) {
|
||||||
|
acc[key] = [];
|
||||||
|
}
|
||||||
|
acc[key]!.push({
|
||||||
|
id: key,
|
||||||
|
breakdowns: breakdowns.map((b, index) => f[`b_${index}`]),
|
||||||
|
level: f.level,
|
||||||
|
count: f.count,
|
||||||
|
});
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
id: string;
|
||||||
|
breakdowns: string[];
|
||||||
|
level: number;
|
||||||
|
count: number;
|
||||||
|
}[]
|
||||||
|
>,
|
||||||
|
);
|
||||||
|
|
||||||
|
return Object.values(series);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getFunnel({
|
||||||
|
projectId,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
events,
|
||||||
|
funnelWindow = 24,
|
||||||
|
funnelGroup,
|
||||||
|
breakdowns = [],
|
||||||
|
}: IChartInput) {
|
||||||
|
if (!startDate || !endDate) {
|
||||||
|
throw new Error('startDate and endDate are required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (events.length === 0) {
|
||||||
|
throw new Error('events are required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const funnelWindowSeconds = funnelWindow * 3600;
|
||||||
|
const group = this.getFunnelGroup(funnelGroup);
|
||||||
|
const funnels = this.getFunnelConditions(events);
|
||||||
|
|
||||||
|
// Create the funnel CTE
|
||||||
|
const funnelCte = clix(this.client)
|
||||||
|
.select([
|
||||||
|
`${group[0]} AS ${group[1]}`,
|
||||||
|
...breakdowns.map(
|
||||||
|
(b, index) => `${getSelectPropertyKey(b.name)} as b_${index}`,
|
||||||
|
),
|
||||||
|
`windowFunnel(${funnelWindowSeconds}, 'strict_increase')(toUnixTimestamp(created_at), ${funnels.join(', ')}) AS level`,
|
||||||
|
])
|
||||||
|
.from(TABLE_NAMES.events, false)
|
||||||
|
.where('project_id', '=', projectId)
|
||||||
|
.where('created_at', 'BETWEEN', [
|
||||||
|
clix.datetime(startDate),
|
||||||
|
clix.datetime(endDate),
|
||||||
|
])
|
||||||
|
.where(
|
||||||
|
'name',
|
||||||
|
'IN',
|
||||||
|
events.map((e) => e.name),
|
||||||
|
)
|
||||||
|
.groupBy([group[1], ...breakdowns.map((b, index) => `b_${index}`)]);
|
||||||
|
|
||||||
|
// Create the sessions CTE if needed
|
||||||
|
const sessionsCte =
|
||||||
|
group[0] !== 'session_id'
|
||||||
|
? clix(this.client)
|
||||||
|
.select(['profile_id', 'id'])
|
||||||
|
.from(TABLE_NAMES.sessions)
|
||||||
|
.where('project_id', '=', projectId)
|
||||||
|
.where('created_at', 'BETWEEN', [
|
||||||
|
clix.datetime(startDate),
|
||||||
|
clix.datetime(endDate),
|
||||||
|
])
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Base funnel query with CTEs
|
||||||
|
const funnelQuery = clix(this.client).with('funnel', funnelCte);
|
||||||
|
|
||||||
|
if (sessionsCte) {
|
||||||
|
funnelQuery.with('sessions', sessionsCte);
|
||||||
|
}
|
||||||
|
|
||||||
|
funnelQuery
|
||||||
|
.select<{
|
||||||
|
level: number;
|
||||||
|
count: number;
|
||||||
|
[key: string]: any;
|
||||||
|
}>([
|
||||||
|
'level',
|
||||||
|
...breakdowns.map((b, index) => `b_${index}`),
|
||||||
|
'count() as count',
|
||||||
|
])
|
||||||
|
.from('funnel')
|
||||||
|
.where('level', '!=', 0)
|
||||||
|
.groupBy(['level', ...breakdowns.map((b, index) => `b_${index}`)])
|
||||||
|
.orderBy('level', 'DESC');
|
||||||
|
|
||||||
|
const funnelData = await funnelQuery.execute();
|
||||||
|
const funnelSeries = this.toSeries(funnelData, breakdowns);
|
||||||
|
|
||||||
|
return funnelSeries
|
||||||
|
.map((data) => {
|
||||||
|
const maxLevel = events.length;
|
||||||
|
const filledFunnelRes = this.fillFunnel(
|
||||||
|
data.map((d) => ({ level: d.level, count: d.count })),
|
||||||
|
maxLevel,
|
||||||
|
);
|
||||||
|
|
||||||
|
const totalSessions = last(filledFunnelRes)?.count ?? 0;
|
||||||
|
const steps = reverse(filledFunnelRes)
|
||||||
|
.reduce(
|
||||||
|
(acc, item, index, list) => {
|
||||||
|
const prev = list[index - 1] ?? { count: totalSessions };
|
||||||
|
const next = list[index + 1];
|
||||||
|
const event = events[item.level - 1]!;
|
||||||
|
return [
|
||||||
|
...acc,
|
||||||
|
{
|
||||||
|
event: {
|
||||||
|
...event,
|
||||||
|
displayName: event.displayName ?? event.name,
|
||||||
|
},
|
||||||
|
count: item.count,
|
||||||
|
percent: (item.count / totalSessions) * 100,
|
||||||
|
dropoffCount: next ? item.count - next.count : null,
|
||||||
|
dropoffPercent: next
|
||||||
|
? ((item.count - next.count) / item.count) * 100
|
||||||
|
: null,
|
||||||
|
previousCount: prev.count,
|
||||||
|
nextCount: next?.count ?? null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
[] as {
|
||||||
|
event: IChartEvent & { displayName: string };
|
||||||
|
count: number;
|
||||||
|
percent: number;
|
||||||
|
dropoffCount: number | null;
|
||||||
|
dropoffPercent: number | null;
|
||||||
|
previousCount: number;
|
||||||
|
nextCount: number | null;
|
||||||
|
}[],
|
||||||
|
)
|
||||||
|
.map((step, index, list) => {
|
||||||
|
const next = list[index + 1];
|
||||||
|
return {
|
||||||
|
...step,
|
||||||
|
isHighestDropoff: (() => {
|
||||||
|
// Skip if current step has no dropoff
|
||||||
|
if (!step?.dropoffCount) return false;
|
||||||
|
|
||||||
|
// Get maximum dropoff count, excluding 0s
|
||||||
|
const maxDropoff = Math.max(
|
||||||
|
...list
|
||||||
|
.map((s) => s.dropoffCount || 0)
|
||||||
|
.filter((count) => count > 0),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if this is the first step with the highest dropoff
|
||||||
|
return (
|
||||||
|
step.dropoffCount === maxDropoff &&
|
||||||
|
list.findIndex((s) => s.dropoffCount === maxDropoff) === index
|
||||||
|
);
|
||||||
|
})(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: data[0]?.id ?? 'none',
|
||||||
|
breakdowns: data[0]?.breakdowns ?? [],
|
||||||
|
steps,
|
||||||
|
totalSessions,
|
||||||
|
lastStep: last(steps)!,
|
||||||
|
mostDropoffsStep: steps.find((step) => step.isHighestDropoff)!,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => {
|
||||||
|
const aTotal = a.steps.reduce((acc, step) => acc + step.count, 0);
|
||||||
|
const bTotal = b.steps.reduce((acc, step) => acc + step.count, 0);
|
||||||
|
return bTotal - aTotal;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const funnelService = new FunnelService(ch);
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
chQuery,
|
chQuery,
|
||||||
createSqlBuilder,
|
createSqlBuilder,
|
||||||
db,
|
db,
|
||||||
|
funnelService,
|
||||||
getSelectPropertyKey,
|
getSelectPropertyKey,
|
||||||
toDate,
|
toDate,
|
||||||
} from '@openpanel/db';
|
} from '@openpanel/db';
|
||||||
@@ -184,9 +185,9 @@ export const chartRouter = createTRPCRouter({
|
|||||||
const previousPeriod = getChartPrevStartEndDate(currentPeriod);
|
const previousPeriod = getChartPrevStartEndDate(currentPeriod);
|
||||||
|
|
||||||
const [current, previous] = await Promise.all([
|
const [current, previous] = await Promise.all([
|
||||||
getFunnelData({ ...input, ...currentPeriod }),
|
funnelService.getFunnel({ ...input, ...currentPeriod }),
|
||||||
input.previous
|
input.previous
|
||||||
? getFunnelData({ ...input, ...previousPeriod })
|
? funnelService.getFunnel({ ...input, ...previousPeriod })
|
||||||
: Promise.resolve(null),
|
: Promise.resolve(null),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user