improve funnels

This commit is contained in:
Carl-Gerhard Lindesvärd
2024-04-22 21:07:17 +02:00
parent 88be927ecd
commit 5b6c67714e
17 changed files with 656 additions and 239 deletions

View File

@@ -45,6 +45,7 @@
"@radix-ui/react-tooltip": "^1.0.7", "@radix-ui/react-tooltip": "^1.0.7",
"@reduxjs/toolkit": "^1.9.7", "@reduxjs/toolkit": "^1.9.7",
"@t3-oss/env-nextjs": "^0.7.3", "@t3-oss/env-nextjs": "^0.7.3",
"@tailwindcss/container-queries": "^0.1.1",
"@tanstack/react-query": "^4.36.1", "@tanstack/react-query": "^4.36.1",
"@tanstack/react-table": "^8.11.8", "@tanstack/react-table": "^8.11.8",
"@trpc/client": "^10.45.1", "@trpc/client": "^10.45.1",

View File

@@ -25,7 +25,7 @@ export default function RootLayout({
return ( return (
<html lang="en" suppressHydrationWarning> <html lang="en" suppressHydrationWarning>
<body <body
className={cn('grainy min-h-screen bg-slate-100 font-sans antialiased')} className={cn('grainy min-h-screen bg-secondary font-sans antialiased')}
> >
<NextTopLoader <NextTopLoader
showSpinner={false} showSpinner={false}

View File

@@ -61,8 +61,8 @@ export function ReportBarChart({ data }: ReportBarChartProps) {
</div> </div>
<Progress <Progress
color={getChartColor(index)} color={getChartColor(index)}
className={cn('w-1/2', editMode ? 'h-5' : 'h-2')}
value={(serie.metrics.sum / maxCount) * 100} value={(serie.metrics.sum / maxCount) * 100}
size={editMode ? 'lg' : 'sm'}
/> />
</div> </div>
</div> </div>

View File

@@ -1,5 +1,4 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { Tooltiper } from '@/components/ui/tooltip';
import type { LucideIcon, LucideProps } from 'lucide-react'; import type { LucideIcon, LucideProps } from 'lucide-react';
import { import {
ActivityIcon, ActivityIcon,
@@ -82,10 +81,8 @@ export function SerieIcon({ name, ...props }: SerieIconProps) {
}, [name]); }, [name]);
return Icon ? ( return Icon ? (
<Tooltiper asChild content={name!}> <div className="[&_a]:![&_a]:!h-4 relative h-4 flex-shrink-0 [&_svg]:!rounded-[2px]">
<div className="[&_a]:![&_a]:!h-4 relative h-4 flex-shrink-0 [&_svg]:!rounded-[2px]"> <Icon size={16} {...props} />
<Icon size={16} {...props} /> </div>
</div>
</Tooltiper>
) : null; ) : null;
} }

View File

@@ -1,8 +1,5 @@
'use client'; 'use client';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { TriangleIcon } from 'lucide-react';
import type { IChartInput } from '@openpanel/validation'; import type { IChartInput } from '@openpanel/validation';
import { Funnel } from '../funnel'; import { Funnel } from '../funnel';
@@ -15,18 +12,7 @@ export const ChartSwitch = withChartProivder(function ChartSwitch(
props: ReportChartProps props: ReportChartProps
) { ) {
if (props.chartType === 'funnel') { if (props.chartType === 'funnel') {
return ( return <Funnel {...props} />;
<>
<Alert>
<TriangleIcon className="h-4 w-4" />
<AlertTitle>Keep in mind</AlertTitle>
<AlertDescription>
Funnel chart is still experimental and might not work as expected.
</AlertDescription>
</Alert>
<Funnel {...props} />
</>
);
} }
return <Chart {...props} />; return <Chart {...props} />;

View File

@@ -0,0 +1,174 @@
'use client';
import {
Carousel,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
} from '@/components/ui/carousel';
import type { RouterOutputs } from '@/trpc/client';
import { cn } from '@/utils/cn';
import { round } from '@/utils/math';
import { ArrowRightIcon } from 'lucide-react';
import { useChartContext } from '../chart/ChartProvider';
function FunnelChart({ from, to }: { from: number; to: number }) {
const fromY = 100 - from;
const toY = 100 - to;
const steps = [
`M0,${fromY}`,
'L0,100',
'L100,100',
`L100,${toY}`,
`L0,${fromY}`,
];
return (
<svg viewBox="0 0 100 100">
<defs>
<linearGradient
id="blue"
x1="50"
y1="100"
x2="50"
y2="0"
gradientUnits="userSpaceOnUse"
>
{/* bottom */}
<stop offset="0%" stop-color="#2564eb" />
{/* top */}
<stop offset="100%" stop-color="#2564eb" />
</linearGradient>
<linearGradient
id="red"
x1="50"
y1="100"
x2="50"
y2="0"
gradientUnits="userSpaceOnUse"
>
{/* bottom */}
<stop offset="0%" stop-color="#f87171" />
{/* top */}
<stop offset="100%" stop-color="#fca5a5" />
</linearGradient>
</defs>
<rect
x="0"
y={fromY}
width="100"
height="100"
fill="url(#red)"
fillOpacity={0.2}
/>
<path d={steps.join(' ')} fill="url(#blue)" />
</svg>
);
}
function getDropoffColor(value: number) {
if (value > 80) {
return 'text-red-600';
}
if (value > 50) {
return 'text-orange-600';
}
if (value > 30) {
return 'text-yellow-600';
}
return 'text-green-600';
}
export function FunnelSteps({
current: { steps, totalSessions },
}: RouterOutputs['chart']['funnel']) {
const { editMode } = useChartContext();
return (
<Carousel className="w-full" opts={{ loop: false, dragFree: true }}>
<CarouselContent>
<CarouselItem className={'flex-[0_0_0] pl-3'} />
{steps.map((step, index, list) => {
const finalStep = index === list.length - 1;
return (
<CarouselItem
className={cn(
'max-w-full flex-[0_0_250px] p-0 px-1',
editMode && 'flex-[0_0_320px]'
)}
key={step.event.id}
>
<div className="card divide-y divide-border bg-background">
<div className="p-4">
<p className="text-muted-foreground">Step {index + 1}</p>
<h3 className="font-bold">
{step.event.displayName || step.event.name}
</h3>
</div>
<div className="relative aspect-square">
<FunnelChart from={step.prevPercent} to={step.percent} />
<div className="absolute left-0 right-0 top-0 flex flex-col bg-background/40 p-4">
<div className="font-medium uppercase text-muted-foreground">
Sessions
</div>
<div className="flex items-center text-3xl font-bold uppercase">
<span className="text-muted-foreground">
{step.before}
</span>
<ArrowRightIcon size={16} className="mx-2" />
<span>{step.current}</span>
</div>
{index !== 0 && (
<>
<div className="text-muted-foreground">
{step.current} of {totalSessions} (
{round(step.percent, 1)}%)
</div>
</>
)}
</div>
</div>
{finalStep ? (
<div className={cn('flex flex-col items-center p-4')}>
<div className="text-xs font-medium uppercase">
Conversion
</div>
<div
className={cn(
'text-3xl font-bold uppercase',
getDropoffColor(step.dropoff.percent)
)}
>
{round(step.percent, 1)}%
</div>
<div className="mt-0 text-sm font-medium uppercase text-muted-foreground">
Converted {step.current} of {totalSessions} sessions
</div>
</div>
) : (
<div className={cn('flex flex-col items-center p-4')}>
<div className="text-xs font-medium uppercase">Dropoff</div>
<div
className={cn(
'text-3xl font-bold uppercase',
getDropoffColor(step.dropoff.percent)
)}
>
{round(step.dropoff.percent, 1)}%
</div>
<div className="mt-0 text-sm font-medium uppercase text-muted-foreground">
Lost {step.dropoff.count} sessions
</div>
</div>
)}
</div>
</CarouselItem>
);
})}
<CarouselItem className={'flex-[0_0_0px] pl-3'} />
</CarouselContent>
<CarouselPrevious />
<CarouselNext />
</Carousel>
);
}

View File

@@ -1,175 +1,225 @@
'use client'; 'use client';
import { import { ColorSquare } from '@/components/color-square';
Carousel, import { AutoSizer } from '@/components/react-virtualized-auto-sizer';
CarouselContent, import { Progress } from '@/components/ui/progress';
CarouselItem, import { Widget, WidgetBody } from '@/components/widget';
CarouselNext, import { pushModal } from '@/modals';
CarouselPrevious, import { useSelector } from '@/redux';
} from '@/components/ui/carousel';
import type { RouterOutputs } from '@/trpc/client'; import type { RouterOutputs } from '@/trpc/client';
import { cn } from '@/utils/cn'; import { cn } from '@/utils/cn';
import { round } from '@/utils/math'; import { round } from '@/utils/math';
import { ArrowRight, ArrowRightIcon } from 'lucide-react'; import { getChartColor } from '@/utils/theme';
import { AlertCircleIcon } from 'lucide-react';
import { last } from 'ramda';
import { Cell, Pie, PieChart } from 'recharts';
import type { IChartInput } from '@openpanel/validation';
import { useChartContext } from '../chart/ChartProvider'; import { useChartContext } from '../chart/ChartProvider';
function FunnelChart({ from, to }: { from: number; to: number }) { const findMostDropoffs = (
const fromY = 100 - from; steps: RouterOutputs['chart']['funnel']['current']['steps']
const toY = 100 - to; ) => {
const steps = [ return steps.reduce((acc, step) => {
`M0,${fromY}`, if (step.dropoffCount > acc.dropoffCount) {
'L0,100', return step;
'L100,100', }
`L100,${toY}`, return acc;
`L0,${fromY}`, });
]; };
function InsightCard({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return ( return (
<svg viewBox="0 0 100 100"> <div className="flex flex-col rounded-lg border border-border p-4 py-3">
<defs> <span className="text-sm">{title}</span>
<linearGradient <div className="whitespace-nowrap text-lg">{children}</div>
id="blue" </div>
x1="50"
y1="100"
x2="50"
y2="0"
gradientUnits="userSpaceOnUse"
>
{/* bottom */}
<stop offset="0%" stop-color="#2564eb" />
{/* top */}
<stop offset="100%" stop-color="#2564eb" />
</linearGradient>
<linearGradient
id="red"
x1="50"
y1="100"
x2="50"
y2="0"
gradientUnits="userSpaceOnUse"
>
{/* bottom */}
<stop offset="0%" stop-color="#f87171" />
{/* top */}
<stop offset="100%" stop-color="#fca5a5" />
</linearGradient>
</defs>
<rect
x="0"
y={fromY}
width="100"
height="100"
fill="url(#red)"
fillOpacity={0.2}
/>
<path d={steps.join(' ')} fill="url(#blue)" />
</svg>
); );
} }
function getDropoffColor(value: number) { type Props = RouterOutputs['chart']['funnel'] & {
if (value > 80) { input: IChartInput;
return 'text-red-600'; };
}
if (value > 50) {
return 'text-orange-600';
}
if (value > 30) {
return 'text-yellow-600';
}
return 'text-green-600';
}
export function FunnelSteps({ export function FunnelSteps({
steps, current: { steps, totalSessions },
totalSessions, previous,
}: RouterOutputs['chart']['funnel']) { input,
}: Props) {
const { editMode } = useChartContext(); const { editMode } = useChartContext();
return ( const mostDropoffs = findMostDropoffs(steps);
<Carousel className="w-full" opts={{ loop: false, dragFree: true }}> const lastStep = last(steps)!;
<CarouselContent> const prevLastStep = last(previous.steps)!;
<CarouselItem className={'flex-[0_0_0] pl-3'} /> const hasIncreased = lastStep.percent > prevLastStep.percent;
{steps.map((step, index, list) => { const withWidget = (children: React.ReactNode) => {
const finalStep = index === list.length - 1; if (editMode) {
return (
<div className="p-4">
<Widget>
<WidgetBody>{children}</WidgetBody>
</Widget>
</div>
);
}
return children;
};
return withWidget(
<div className="flex flex-col gap-4 @container">
<div
className={cn(
'rounded-lg border border-border',
!editMode && 'border-0 p-0'
)}
>
<div className="flex items-center gap-8 p-4">
<div className="hidden shrink-0 @xl:block @xl:w-36">
<AutoSizer disableHeight>
{({ width }) => {
const height = width;
return (
<div className="relative" style={{ width, height }}>
<PieChart width={width} height={height}>
<Pie
data={[
{
value: lastStep.percent,
label: 'Conversion',
},
{
value: 100 - lastStep.percent,
label: 'Dropoff',
},
]}
innerRadius={height / 3}
outerRadius={height / 2 - 10}
isAnimationActive={true}
nameKey="label"
dataKey="value"
>
<Cell strokeWidth={0} className="fill-blue-600" />
<Cell strokeWidth={0} className="fill-slate-200" />
</Pie>
</PieChart>
<div
className="absolute inset-0 flex items-center justify-center font-mono font-bold"
style={{
fontSize: width / 6,
}}
>
<div>{round(lastStep.percent, 2)}%</div>
</div>
</div>
);
}}
</AutoSizer>
</div>
<div>
<div className="mb-1 text-xl font-semibold">Insights</div>
<div className="flex flex-wrap gap-4">
<InsightCard title="Converted">
<span className="font-bold">{lastStep.count}</span>
<span className="mx-2 text-muted-foreground">of</span>
<span className="text-muted-foreground">{totalSessions}</span>
</InsightCard>
<InsightCard
title={hasIncreased ? 'Trending up' : 'Trending down'}
>
<span className="font-bold">{round(lastStep.percent, 2)}%</span>
<span className="mx-2 text-muted-foreground">compared to</span>
<span className="text-muted-foreground">
{round(prevLastStep.percent, 2)}%
</span>
</InsightCard>
<InsightCard title={'Most dropoffs'}>
<span className="font-bold">
{mostDropoffs.event.displayName}
</span>
<span className="mx-2 text-muted-foreground">lost</span>
<span className="text-muted-foreground">
{mostDropoffs.dropoffCount} sessions
</span>
</InsightCard>
</div>
</div>
</div>
</div>
<div className="flex flex-col gap-1 divide-y">
{steps.map((step, index) => {
const percent = (step.count / totalSessions) * 100;
const isMostDropoffs = mostDropoffs.event.id === step.event.id;
return ( return (
<CarouselItem <div
className={cn(
'max-w-full flex-[0_0_250px] p-0 px-1',
editMode && 'flex-[0_0_320px]'
)}
key={step.event.id} key={step.event.id}
className="flex flex-col gap-4 px-4 py-4 @2xl:flex-row @2xl:px-8"
> >
<div className="card divide-y divide-border bg-background"> <div className="relative flex flex-1 flex-col gap-2 pl-8">
<div className="p-4"> <ColorSquare className="absolute left-0 top-0.5">
<p className="text-muted-foreground">Step {index + 1}</p> {step.event.id}
<h3 className="font-bold"> </ColorSquare>
{step.event.displayName || step.event.name} <div className="font-semibold capitalize">
</h3> {step.event.displayName.replace(/_/g, ' ')}
</div> </div>
<div className="relative aspect-square"> <div className="flex items-center gap-4 text-sm">
<FunnelChart from={step.prevPercent} to={step.percent} /> <div className="flex flex-col">
<div className="absolute left-0 right-0 top-0 flex flex-col bg-background/40 p-4"> <span className="text-xs text-muted-foreground">
<div className="font-medium uppercase text-muted-foreground"> Total:
Sessions </span>
</div> <span className="font-semibold">{step.previousCount}</span>
<div className="flex items-center text-3xl font-bold uppercase">
<span className="text-muted-foreground">
{step.before}
</span>
<ArrowRightIcon size={16} className="mx-2" />
<span>{step.current}</span>
</div>
{index !== 0 && (
<>
<div className="text-muted-foreground">
{step.current} of {totalSessions} (
{round(step.percent, 1)}%)
</div>
</>
)}
</div> </div>
</div> <div className="flex flex-col">
{finalStep ? ( <span className="text-xs text-muted-foreground">
<div className={cn('flex flex-col items-center p-4')}> Dropoff:
<div className="text-xs font-medium uppercase"> </span>
Conversion <span
</div>
<div
className={cn( className={cn(
'text-3xl font-bold uppercase', 'flex items-center gap-1 font-semibold',
getDropoffColor(step.dropoff.percent) isMostDropoffs && 'text-red-600'
)} )}
> >
{round(step.percent, 1)}% {isMostDropoffs && <AlertCircleIcon size={14} />}
</div> {step.dropoffCount}
<div className="mt-0 text-sm font-medium uppercase text-muted-foreground"> </span>
Converted {step.current} of {totalSessions} sessions </div>
<div className="flex flex-col">
<span className="text-xs text-muted-foreground">
Current:
</span>
<div>
<span className="font-semibold">{step.count}</span>
<button
className="ml-2 underline"
onClick={() =>
pushModal('FunnelStepDetails', {
...input,
step: index + 1,
})
}
>
Inspect
</button>
</div> </div>
</div> </div>
) : ( </div>
<div className={cn('flex flex-col items-center p-4')}>
<div className="text-xs font-medium uppercase">Dropoff</div>
<div
className={cn(
'text-3xl font-bold uppercase',
getDropoffColor(step.dropoff.percent)
)}
>
{round(step.dropoff.percent, 1)}%
</div>
<div className="mt-0 text-sm font-medium uppercase text-muted-foreground">
Lost {step.dropoff.count} sessions
</div>
</div>
)}
</div> </div>
</CarouselItem> <Progress
size="lg"
className="w-full @2xl:w-1/2"
color={getChartColor(index)}
value={percent}
/>
</div>
); );
})} })}
<CarouselItem className={'flex-[0_0_0px] pl-3'} /> </div>
</CarouselContent> </div>
<CarouselPrevious />
<CarouselNext />
</Carousel>
); );
} }

View File

@@ -19,35 +19,33 @@ export const Funnel = withChartProivder(function Chart({
range, range,
projectId, projectId,
}: ReportChartProps) { }: ReportChartProps) {
const [data] = api.chart.funnel.useSuspenseQuery( const input: IChartInput = {
{ events,
events, name,
name, range,
range, projectId,
projectId, lineType: 'monotone',
lineType: 'monotone', interval: 'day',
interval: 'day', chartType: 'funnel',
chartType: 'funnel', breakdowns: [],
breakdowns: [], startDate: null,
startDate: null, endDate: null,
endDate: null, previous: false,
previous: false, formula: undefined,
formula: undefined, unit: undefined,
unit: undefined, metric: 'sum',
metric: 'sum', };
}, const [data] = api.chart.funnel.useSuspenseQuery(input, {
{ keepPreviousData: true,
keepPreviousData: true, });
}
);
if (data.steps.length === 0) { if (data.current.steps.length === 0) {
return <ChartEmpty />; return <ChartEmpty />;
} }
return ( return (
<div className="-mx-4"> <div className="-m-4">
<FunnelSteps {...data} /> <FunnelSteps {...data} input={input} />
</div> </div>
); );
}); });

View File

@@ -1,28 +1,37 @@
import * as React from 'react'; import * as React from 'react';
import { cn } from '@/utils/cn'; import { cn } from '@/utils/cn';
import { round } from '@/utils/math';
import * as ProgressPrimitive from '@radix-ui/react-progress'; import * as ProgressPrimitive from '@radix-ui/react-progress';
const Progress = React.forwardRef< const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>, React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root> & { React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root> & {
color: string; color: string;
size?: 'sm' | 'default' | 'lg';
} }
>(({ className, value, color, ...props }, ref) => ( >(({ className, value, color, size = 'default', ...props }, ref) => (
<ProgressPrimitive.Root <ProgressPrimitive.Root
ref={ref} ref={ref}
className={cn( className={cn(
'relative h-4 w-full overflow-hidden rounded-full bg-secondary', 'relative h-4 w-full min-w-16 overflow-hidden rounded bg-slate-200 shadow-sm',
size == 'sm' && 'h-2',
size == 'lg' && 'h-8',
className className
)} )}
{...props} {...props}
> >
<ProgressPrimitive.Indicator <ProgressPrimitive.Indicator
className={'h-full w-full flex-1 bg-primary transition-all'} className={'h-full w-full flex-1 rounded bg-primary transition-all'}
style={{ style={{
transform: `translateX(-${100 - (value || 0)}%)`, transform: `translateX(-${100 - (value || 0)}%)`,
background: color, background: color,
}} }}
/> />
{value && size != 'sm' && (
<div className="z-5 absolute bottom-0 top-0 flex items-center px-2 text-xs font-semibold">
<div>{round(value, 2)}%</div>
</div>
)}
</ProgressPrimitive.Root> </ProgressPrimitive.Root>
)); ));
Progress.displayName = ProgressPrimitive.Root.displayName; Progress.displayName = ProgressPrimitive.Root.displayName;

View File

@@ -18,7 +18,7 @@ export function WidgetTable<T>({
}: Props<T>) { }: Props<T>) {
return ( return (
<table className={cn('w-full', className)}> <table className={cn('w-full', className)}>
<thead className="border-b border-border bg-slate-50 text-sm text-slate-500 [&_th:last-child]:text-right [&_th]:whitespace-nowrap [&_th]:p-4 [&_th]:py-2 [&_th]:text-left [&_th]:font-medium"> <thead className="sticky top-0 z-50 border-b border-border bg-slate-50 text-sm text-slate-500 [&_th:last-child]:text-right [&_th]:whitespace-nowrap [&_th]:p-4 [&_th]:py-2 [&_th]:text-left [&_th]:font-medium">
<tr> <tr>
{columns.map((column) => ( {columns.map((column) => (
<th key={column.name}>{column.name}</th> <th key={column.name}>{column.name}</th>

View File

@@ -0,0 +1,104 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { ListPropertiesIcon } from '@/components/events/list-properties-icon';
import { Pagination } from '@/components/pagination';
import { ProfileAvatar } from '@/components/profiles/profile-avatar';
import { DialogContent } from '@/components/ui/dialog';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Tooltiper } from '@/components/ui/tooltip';
import { WidgetTable } from '@/components/widget-table';
import { useAppParams } from '@/hooks/useAppParams';
import { api } from '@/trpc/client';
import { getProfileName } from '@/utils/getters';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import type { IChartInput } from '@openpanel/validation';
import { popModal } from '.';
import { ModalHeader } from './Modal/Container';
interface Props extends IChartInput {
step: number;
}
function usePrevious(value: any) {
const ref = useRef();
useEffect(() => {
ref.current = value;
});
return ref.current;
}
export default function FunnelStepDetails(props: Props) {
const [data] = api.chart.funnelStep.useSuspenseQuery(props);
const pathname = usePathname();
const prev = usePrevious(pathname);
const { organizationSlug, projectId } = useAppParams();
const [page, setPage] = useState(0);
useEffect(() => {
if (prev && prev !== pathname) {
popModal();
}
}, [pathname]);
return (
<DialogContent className="p-0">
<div className="p-4">
<ModalHeader title="Profiles"></ModalHeader>
<Pagination
count={data.length}
take={50}
cursor={page}
setCursor={setPage}
/>
</div>
<ScrollArea className="max-h-[60vh]">
<WidgetTable
data={data.slice(page * 50, page * 50 + 50)}
keyExtractor={(item) => item.id}
columns={[
{
name: 'Name',
render(profile) {
return (
<Link
prefetch={false}
href={`/${organizationSlug}/${projectId}/profiles/${profile.id}`}
className="flex items-center gap-2 font-medium"
>
<ProfileAvatar size="sm" {...profile} />
{getProfileName(profile)}
</Link>
);
},
},
{
name: '',
render(profile) {
return <ListPropertiesIcon {...profile.properties} />;
},
},
{
name: 'Last seen',
render(profile) {
return (
<Tooltiper
asChild
content={profile.createdAt.toLocaleString()}
>
<div className="text-sm text-muted-foreground">
{profile.createdAt.toLocaleTimeString()}
</div>
</Tooltiper>
);
},
},
]}
/>
</ScrollArea>
</DialogContent>
);
}

View File

@@ -56,6 +56,9 @@ const modals = {
VerifyEmail: dynamic(() => import('./VerifyEmail'), { VerifyEmail: dynamic(() => import('./VerifyEmail'), {
loading: Loading, loading: Loading,
}), }),
FunnelStepDetails: dynamic(() => import('./FunnelStepDetails'), {
loading: Loading,
}),
}; };
export const { pushModal, popModal, popAllModals, ModalProvider } = export const { pushModal, popModal, popAllModals, ModalProvider } =

View File

@@ -12,6 +12,8 @@ import {
formatClickhouseDate, formatClickhouseDate,
getChartSql, getChartSql,
getEventFiltersWhereClause, getEventFiltersWhereClause,
getProfiles,
transformProfile,
} from '@openpanel/db'; } from '@openpanel/db';
import type { import type {
IChartEvent, IChartEvent,
@@ -183,7 +185,6 @@ export function withFormula(
const scope = { const scope = {
[serie.event.id]: item?.count ?? 0, [serie.event.id]: item?.count ?? 0,
}; };
const count = mathjs const count = mathjs
.parse(formula) .parse(formula)
.compile() .compile()
@@ -418,8 +419,17 @@ export function getChartPrevStartEndDate({
}; };
} }
export async function getFunnelData({ projectId, ...payload }: IChartInput) { const ONE_DAY_IN_SECONDS = 60 * 60 * 24;
const { startDate, endDate } = getChartStartEndDate(payload);
export async function getFunnelData({
projectId,
startDate,
endDate,
...payload
}: IChartInput) {
if (!startDate || !endDate) {
throw new Error('startDate and endDate are required');
}
if (payload.events.length === 0) { if (payload.events.length === 0) {
return { return {
@@ -437,9 +447,13 @@ export async function getFunnelData({ projectId, ...payload }: IChartInput) {
const innerSql = `SELECT const innerSql = `SELECT
session_id, session_id,
windowFunnel(6048000000000000,'strict_increase')(toUnixTimestamp(created_at), ${funnels.join(', ')}) AS level windowFunnel(${ONE_DAY_IN_SECONDS})(toUnixTimestamp(created_at), ${funnels.join(', ')}) AS level
FROM events FROM events
WHERE (project_id = ${escape(projectId)} AND created_at >= '${formatClickhouseDate(startDate)}') AND (created_at <= '${formatClickhouseDate(endDate)}') WHERE
project_id = ${escape(projectId)} AND
created_at >= '${formatClickhouseDate(startDate)}' AND
created_at <= '${formatClickhouseDate(endDate)}' AND
name IN (${payload.events.map((event) => escape(event.name)).join(', ')})
GROUP BY session_id`; GROUP BY session_id`;
const sql = `SELECT level, count() AS count FROM (${innerSql}) GROUP BY level ORDER BY level DESC`; const sql = `SELECT level, count() AS count FROM (${innerSql}) GROUP BY level ORDER BY level DESC`;
@@ -491,31 +505,29 @@ export async function getFunnelData({ projectId, ...payload }: IChartInput) {
.reduce( .reduce(
(acc, item, index, list) => { (acc, item, index, list) => {
const prev = list[index - 1] ?? { count: totalSessions }; const prev = list[index - 1] ?? { count: totalSessions };
const event = payload.events[item.level - 1]!;
return [ return [
...acc, ...acc,
{ {
event: payload.events[item.level - 1]!, event: {
before: prev.count, ...event,
current: item.count, displayName: event.displayName ?? event.name,
dropoff: {
count: prev.count - item.count,
percent: 100 - (item.count / prev.count) * 100,
}, },
count: item.count,
percent: (item.count / totalSessions) * 100, percent: (item.count / totalSessions) * 100,
prevPercent: (prev.count / totalSessions) * 100, dropoffCount: prev.count - item.count,
dropoffPercent: 100 - (item.count / prev.count) * 100,
previousCount: prev.count,
}, },
]; ];
}, },
[] as { [] as {
event: IChartEvent; event: IChartEvent & { displayName: string };
before: number; count: number;
current: number;
dropoff: {
count: number;
percent: number;
};
percent: number; percent: number;
prevPercent: number; dropoffCount: number;
dropoffPercent: number;
previousCount: number;
}[] }[]
); );
@@ -525,6 +537,63 @@ export async function getFunnelData({ projectId, ...payload }: IChartInput) {
}; };
} }
export async function getFunnelStep({
projectId,
startDate,
endDate,
step,
...payload
}: IChartInput & {
step: number;
}) {
if (!startDate || !endDate) {
throw new Error('startDate and endDate are required');
}
if (payload.events.length === 0) {
throw new Error('no events selected');
}
const funnels = payload.events.map((event) => {
const { sb, getWhere } = createSqlBuilder();
sb.where = getEventFiltersWhereClause(event.filters);
sb.where.name = `name = ${escape(event.name)}`;
return getWhere().replace('WHERE ', '');
});
const innerSql = `SELECT
session_id,
windowFunnel(${ONE_DAY_IN_SECONDS})(toUnixTimestamp(created_at), ${funnels.join(', ')}) AS level
FROM events
WHERE
project_id = ${escape(projectId)} AND
created_at >= '${formatClickhouseDate(startDate)}' AND
created_at <= '${formatClickhouseDate(endDate)}' AND
name IN (${payload.events.map((event) => escape(event.name)).join(', ')})
GROUP BY session_id`;
const profileIdsQuery = `WITH sessions AS (${innerSql})
SELECT
DISTINCT e.profile_id as id
FROM sessions s
JOIN events e ON s.session_id = e.session_id
WHERE
s.level = ${step} AND
e.project_id = ${escape(projectId)} AND
e.created_at >= '${formatClickhouseDate(startDate)}' AND
e.created_at <= '${formatClickhouseDate(endDate)}' AND
name IN (${payload.events.map((event) => escape(event.name)).join(', ')})
ORDER BY e.created_at DESC
LIMIT 500
`;
const res = await chQuery<{
id: string;
}>(profileIdsQuery);
return getProfiles({ ids: res.map((r) => r.id) });
}
export async function getSeriesFromEvents(input: IChartInput) { export async function getSeriesFromEvents(input: IChartInput) {
const { startDate, endDate } = const { startDate, endDate } =
input.startDate && input.endDate input.startDate && input.endDate

View File

@@ -16,6 +16,7 @@ import {
getChartPrevStartEndDate, getChartPrevStartEndDate,
getChartStartEndDate, getChartStartEndDate,
getFunnelData, getFunnelData,
getFunnelStep,
getSeriesFromEvents, getSeriesFromEvents,
} from './chart.helpers'; } from './chart.helpers';
@@ -150,9 +151,34 @@ export const chartRouter = createTRPCRouter({
}), }),
funnel: publicProcedure.input(zChartInput).query(async ({ input }) => { funnel: publicProcedure.input(zChartInput).query(async ({ input }) => {
return getFunnelData(input); const currentPeriod = getChartStartEndDate(input);
const previousPeriod = getChartPrevStartEndDate({
range: input.range,
...currentPeriod,
});
const [current, previous] = await Promise.all([
getFunnelData({ ...input, ...currentPeriod }),
getFunnelData({ ...input, ...previousPeriod }),
]);
return {
current,
previous,
};
}), }),
funnelStep: publicProcedure
.input(
zChartInput.extend({
step: z.number(),
})
)
.query(async ({ input }) => {
const currentPeriod = getChartStartEndDate(input);
return getFunnelStep({ ...input, ...currentPeriod });
}),
// TODO: Make this private // TODO: Make this private
chart: publicProcedure.input(zChartInput).query(async ({ input }) => { chart: publicProcedure.input(zChartInput).query(async ({ input }) => {
const currentPeriod = getChartStartEndDate(input); const currentPeriod = getChartStartEndDate(input);
@@ -189,7 +215,10 @@ export const chartRouter = createTRPCRouter({
return { return {
name: serie.name, name: serie.name,
event: serie.event, event: {
...serie.event,
displayName: serie.event.displayName ?? serie.event.name,
},
metrics: { metrics: {
...metrics, ...metrics,
previous: { previous: {

View File

@@ -155,7 +155,10 @@ const config = {
}, },
}, },
}, },
plugins: [require('tailwindcss-animate')], plugins: [
require('@tailwindcss/container-queries'),
require('tailwindcss-animate'),
],
}; };
export default config; export default config;

View File

@@ -122,8 +122,7 @@ export async function getProfilesByExternalId(
${getProfileSelectFields()} ${getProfileSelectFields()}
FROM profiles FROM profiles
GROUP BY id GROUP BY id
HAVING project_id = ${escape(projectId)} AND external_id = ${escape(externalId)} HAVING project_id = ${escape(projectId)} AND external_id = ${escape(externalId)}`
`
); );
return data.map(transformProfile); return data.map(transformProfile);
@@ -169,7 +168,7 @@ export interface IServiceUpsertProfile {
properties?: Record<string, unknown>; properties?: Record<string, unknown>;
} }
function transformProfile({ export function transformProfile({
max_created_at, max_created_at,
first_name, first_name,
last_name, last_name,

29
pnpm-lock.yaml generated
View File

@@ -225,6 +225,9 @@ importers:
'@t3-oss/env-nextjs': '@t3-oss/env-nextjs':
specifier: ^0.7.3 specifier: ^0.7.3
version: 0.7.3(typescript@5.3.3)(zod@3.22.4) version: 0.7.3(typescript@5.3.3)(zod@3.22.4)
'@tailwindcss/container-queries':
specifier: ^0.1.1
version: 0.1.1(tailwindcss@3.4.1)
'@tanstack/react-query': '@tanstack/react-query':
specifier: ^4.36.1 specifier: ^4.36.1
version: 4.36.1(react-dom@18.2.0)(react@18.2.0) version: 4.36.1(react-dom@18.2.0)(react@18.2.0)
@@ -6879,6 +6882,14 @@ packages:
zod: 3.22.4 zod: 3.22.4
dev: false dev: false
/@tailwindcss/container-queries@0.1.1(tailwindcss@3.4.1):
resolution: {integrity: sha512-p18dswChx6WnTSaJCSGx6lTmrGzNNvm2FtXmiO6AuA1V4U5REyoqwmT6kgAsIMdjo07QdAfYXHJ4hnMtfHzWgA==}
peerDependencies:
tailwindcss: '>=3.2.0'
dependencies:
tailwindcss: 3.4.1
dev: false
/@tailwindcss/typography@0.5.10(tailwindcss@3.4.1): /@tailwindcss/typography@0.5.10(tailwindcss@3.4.1):
resolution: {integrity: sha512-Pe8BuPJQJd3FfRnm6H0ulKIGoMEQS+Vq01R6M5aCrFB/ccR/shT+0kXLjouGC1gFLm9hopTFN+DMP0pfwRWzPw==} resolution: {integrity: sha512-Pe8BuPJQJd3FfRnm6H0ulKIGoMEQS+Vq01R6M5aCrFB/ccR/shT+0kXLjouGC1gFLm9hopTFN+DMP0pfwRWzPw==}
peerDependencies: peerDependencies:
@@ -14825,22 +14836,6 @@ packages:
camelcase-css: 2.0.1 camelcase-css: 2.0.1
postcss: 8.4.35 postcss: 8.4.35
/postcss-load-config@4.0.2:
resolution: {integrity: sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==}
engines: {node: '>= 14'}
peerDependencies:
postcss: '>=8.0.9'
ts-node: '>=9.0.0'
peerDependenciesMeta:
postcss:
optional: true
ts-node:
optional: true
dependencies:
lilconfig: 3.1.0
yaml: 2.3.4
dev: true
/postcss-load-config@4.0.2(postcss@8.4.35): /postcss-load-config@4.0.2(postcss@8.4.35):
resolution: {integrity: sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==} resolution: {integrity: sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==}
engines: {node: '>= 14'} engines: {node: '>= 14'}
@@ -17141,7 +17136,7 @@ packages:
execa: 5.1.1 execa: 5.1.1
globby: 11.1.0 globby: 11.1.0
joycon: 3.1.1 joycon: 3.1.1
postcss-load-config: 4.0.2 postcss-load-config: 4.0.2(postcss@8.4.35)
resolve-from: 5.0.0 resolve-from: 5.0.0
rollup: 4.12.0 rollup: 4.12.0
source-map: 0.8.0-beta.0 source-map: 0.8.0-beta.0