improve(funnel): make sure group by profile id works as you would think
This commit is contained in:
@@ -29,7 +29,7 @@ interface PreviousDiffIndicatorProps {
|
|||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
inverted?: boolean;
|
inverted?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
size?: 'sm' | 'lg';
|
size?: 'sm' | 'lg' | 'md';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PreviousDiffIndicator({
|
export function PreviousDiffIndicator({
|
||||||
@@ -80,6 +80,7 @@ export function PreviousDiffIndicator({
|
|||||||
'flex size-4 items-center justify-center rounded-full',
|
'flex size-4 items-center justify-center rounded-full',
|
||||||
variant,
|
variant,
|
||||||
size === 'lg' && 'size-8',
|
size === 'lg' && 'size-8',
|
||||||
|
size === 'md' && 'size-6',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{renderIcon()}
|
{renderIcon()}
|
||||||
|
|||||||
@@ -39,15 +39,10 @@ export function Chart({
|
|||||||
const { isEditMode } = useReportChartContext();
|
const { isEditMode } = useReportChartContext();
|
||||||
const mostDropoffs = findMostDropoffs(steps);
|
const mostDropoffs = findMostDropoffs(steps);
|
||||||
const lastStep = last(steps)!;
|
const lastStep = last(steps)!;
|
||||||
const prevLastStep = last(previous.steps);
|
const prevLastStep = previous?.steps ? last(previous.steps) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={cn('col gap-4 @container', isEditMode ? 'card' : '-m-4')}>
|
||||||
className={cn(
|
|
||||||
'flex flex-col gap-4 @container',
|
|
||||||
isEditMode ? 'card' : '-m-4',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'border-b border-border bg-def-100',
|
'border-b border-border bg-def-100',
|
||||||
@@ -55,66 +50,49 @@ export function Chart({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-8 p-4 px-8">
|
<div className="flex items-center gap-8 p-4 px-8">
|
||||||
<div className="flex flex-1 items-center gap-8">
|
<div className="flex flex-1 items-center gap-8 min-w-0">
|
||||||
<MetricCardNumber
|
<MetricCardNumber
|
||||||
label="Converted"
|
label="Converted"
|
||||||
value={lastStep.count}
|
value={lastStep.count}
|
||||||
enhancer={
|
enhancer={
|
||||||
<PreviousDiffIndicator
|
<PreviousDiffIndicator
|
||||||
size="lg"
|
size="md"
|
||||||
{...getPreviousMetric(lastStep.count, prevLastStep?.count)}
|
{...getPreviousMetric(lastStep.count, prevLastStep?.count)}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<MetricCardNumber
|
<MetricCardNumber
|
||||||
label="Percent"
|
label="Percent"
|
||||||
value={`${totalSessions ? round((lastStep.count / totalSessions) * 100, 1) : 0}%`}
|
value={`${totalSessions ? round((lastStep.count / totalSessions) * 100, 2) : 0}%`}
|
||||||
enhancer={
|
enhancer={
|
||||||
<PreviousDiffIndicator
|
<PreviousDiffIndicator
|
||||||
size="lg"
|
size="md"
|
||||||
{...getPreviousMetric(lastStep.count, prevLastStep?.count)}
|
{...getPreviousMetric(lastStep.count, prevLastStep?.count)}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<MetricCardNumber
|
<MetricCardNumber
|
||||||
|
className="flex-1"
|
||||||
label="Most dropoffs"
|
label="Most dropoffs"
|
||||||
value={mostDropoffs.event.displayName}
|
value={mostDropoffs.event.displayName}
|
||||||
enhancer={
|
enhancer={
|
||||||
<PreviousDiffIndicator
|
<PreviousDiffIndicator
|
||||||
size="lg"
|
size="md"
|
||||||
{...getPreviousMetric(lastStep.count, prevLastStep?.count)}
|
{...getPreviousMetric(lastStep.count, prevLastStep?.count)}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden shrink-0 gap-2 @xl:flex">
|
|
||||||
{steps.map((step) => {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="flex h-16 w-4 items-end overflow-hidden rounded bg-def-200"
|
|
||||||
key={step.event.id}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'bg-foreground w-full',
|
|
||||||
step.event.id === mostDropoffs.event.id && 'bg-rose-500',
|
|
||||||
)}
|
|
||||||
style={{ height: `${step.percent}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="col divide-y divide-def-200">
|
||||||
<div className="flex flex-col divide-y divide-def-200">
|
|
||||||
{steps.map((step, index) => {
|
{steps.map((step, index) => {
|
||||||
const percent = (step.count / totalSessions) * 100;
|
const percent = (step.count / totalSessions) * 100;
|
||||||
const isMostDropoffs = mostDropoffs.event.id === step.event.id;
|
const isMostDropoffs = mostDropoffs.event.id === step.event.id;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={step.event.id}
|
key={step.event.id}
|
||||||
className="flex flex-col gap-4 px-4 py-4 @2xl:flex-row @2xl:px-8"
|
className="col gap-12 px-4 py-4 @2xl:flex-row @2xl:px-8"
|
||||||
>
|
>
|
||||||
<div className="relative flex flex-1 flex-col gap-2 pl-8">
|
<div className="relative flex flex-1 flex-col gap-2 pl-8">
|
||||||
<ColorSquare className="absolute left-0 top-0.5">
|
<ColorSquare className="absolute left-0 top-0.5">
|
||||||
@@ -123,27 +101,27 @@ export function Chart({
|
|||||||
<div className="font-semibold mt-1">
|
<div className="font-semibold mt-1">
|
||||||
{step.event.displayName}
|
{step.event.displayName}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-8 text-sm">
|
<div className="grid grid-cols-4 max-w-lg gap-8 text-sm">
|
||||||
<TooltipComplete
|
<TooltipComplete
|
||||||
disabled={!previous.steps[index]}
|
disabled={!previous?.steps?.[index]}
|
||||||
content={
|
content={
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span>
|
<span>
|
||||||
Last period:{' '}
|
Last period:{' '}
|
||||||
<span className="font-mono">
|
<span className="font-mono">
|
||||||
{previous.steps[index]?.previousCount}
|
{previous?.steps?.[index]?.previousCount}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<PreviousDiffIndicator
|
<PreviousDiffIndicator
|
||||||
{...getPreviousMetric(
|
{...getPreviousMetric(
|
||||||
step.previousCount,
|
step.previousCount,
|
||||||
previous.steps[index]?.previousCount,
|
previous?.steps?.[index]?.previousCount,
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="col gap-2">
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
Total:
|
Total:
|
||||||
</span>
|
</span>
|
||||||
@@ -155,26 +133,26 @@ export function Chart({
|
|||||||
</div>
|
</div>
|
||||||
</TooltipComplete>
|
</TooltipComplete>
|
||||||
<TooltipComplete
|
<TooltipComplete
|
||||||
disabled={!previous.steps[index]}
|
disabled={!previous?.steps?.[index]}
|
||||||
content={
|
content={
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span>
|
<span>
|
||||||
Last period:{' '}
|
Last period:{' '}
|
||||||
<span className="font-mono">
|
<span className="font-mono">
|
||||||
{previous.steps[index]?.dropoffCount}
|
{previous?.steps?.[index]?.dropoffCount}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<PreviousDiffIndicator
|
<PreviousDiffIndicator
|
||||||
inverted
|
inverted
|
||||||
{...getPreviousMetric(
|
{...getPreviousMetric(
|
||||||
step.dropoffCount,
|
step.dropoffCount,
|
||||||
previous.steps[index]?.dropoffCount,
|
previous?.steps?.[index]?.dropoffCount,
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="col gap-2">
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
Dropoff:
|
Dropoff:
|
||||||
</span>
|
</span>
|
||||||
@@ -192,25 +170,25 @@ export function Chart({
|
|||||||
</div>
|
</div>
|
||||||
</TooltipComplete>
|
</TooltipComplete>
|
||||||
<TooltipComplete
|
<TooltipComplete
|
||||||
disabled={!previous.steps[index]}
|
disabled={!previous?.steps?.[index]}
|
||||||
content={
|
content={
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span>
|
<span>
|
||||||
Last period:{' '}
|
Last period:{' '}
|
||||||
<span className="font-mono">
|
<span className="font-mono">
|
||||||
{previous.steps[index]?.count}
|
{previous?.steps?.[index]?.count}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<PreviousDiffIndicator
|
<PreviousDiffIndicator
|
||||||
{...getPreviousMetric(
|
{...getPreviousMetric(
|
||||||
step.count,
|
step.count,
|
||||||
previous.steps[index]?.count,
|
previous?.steps?.[index]?.count,
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="col gap-2">
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
Current:
|
Current:
|
||||||
</span>
|
</span>
|
||||||
@@ -219,12 +197,42 @@ export function Chart({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TooltipComplete>
|
</TooltipComplete>
|
||||||
|
<TooltipComplete
|
||||||
|
disabled={!previous?.steps?.[index]}
|
||||||
|
content={
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span>
|
||||||
|
Last period:{' '}
|
||||||
|
<span className="font-mono">
|
||||||
|
{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>
|
</div>
|
||||||
<Progress
|
<Progress
|
||||||
size="lg"
|
size="lg"
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full @2xl:w-1/2 text-white bg-def-200 mt-0.5 dark:text-black',
|
'w-full @2xl:w-1/4 text-white bg-def-200 mt-0.5 dark:text-black',
|
||||||
)}
|
)}
|
||||||
innerClassName={cn(
|
innerClassName={cn(
|
||||||
'bg-primary',
|
'bg-primary',
|
||||||
|
|||||||
@@ -13,7 +13,16 @@ import { Chart } from './chart';
|
|||||||
|
|
||||||
export function ReportFunnelChart() {
|
export function ReportFunnelChart() {
|
||||||
const {
|
const {
|
||||||
report: { events, range, projectId, funnelWindow, funnelGroup },
|
report: {
|
||||||
|
events,
|
||||||
|
range,
|
||||||
|
projectId,
|
||||||
|
funnelWindow,
|
||||||
|
funnelGroup,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
previous,
|
||||||
|
},
|
||||||
isLazyLoading,
|
isLazyLoading,
|
||||||
} = useReportChartContext();
|
} = useReportChartContext();
|
||||||
|
|
||||||
@@ -26,8 +35,10 @@ export function ReportFunnelChart() {
|
|||||||
breakdowns: [],
|
breakdowns: [],
|
||||||
funnelWindow,
|
funnelWindow,
|
||||||
funnelGroup,
|
funnelGroup,
|
||||||
previous: false,
|
previous,
|
||||||
metric: 'sum',
|
metric: 'sum',
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
};
|
};
|
||||||
const res = api.chart.funnel.useQuery(input, {
|
const res = api.chart.funnel.useQuery(input, {
|
||||||
keepPreviousData: true,
|
keepPreviousData: true,
|
||||||
|
|||||||
@@ -122,19 +122,21 @@ export function MetricCardNumber({
|
|||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
enhancer,
|
enhancer,
|
||||||
|
className,
|
||||||
}: {
|
}: {
|
||||||
label: React.ReactNode;
|
label: React.ReactNode;
|
||||||
value: React.ReactNode;
|
value: React.ReactNode;
|
||||||
enhancer?: React.ReactNode;
|
enhancer?: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-w-0 flex-col gap-2">
|
<div className={cn('flex min-w-0 flex-col gap-2', className)}>
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<div className="flex min-w-0 items-center gap-2 text-left">
|
<div className="flex min-w-0 items-center gap-2 text-left">
|
||||||
<span className="truncate text-muted-foreground">{label}</span>
|
<span className="truncate text-muted-foreground">{label}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-end justify-between">
|
<div className="flex items-end justify-between gap-4">
|
||||||
<div className="truncate font-mono text-3xl font-bold">{value}</div>
|
<div className="truncate font-mono text-3xl font-bold">{value}</div>
|
||||||
{enhancer}
|
{enhancer}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,32 @@
|
|||||||
import { useDispatch, useSelector } from '@/redux';
|
import { useDispatch, useSelector } from '@/redux';
|
||||||
import { LineChartIcon } from 'lucide-react';
|
import {
|
||||||
|
AreaChartIcon,
|
||||||
|
ChartBarIcon,
|
||||||
|
ChartColumnIncreasingIcon,
|
||||||
|
ConeIcon,
|
||||||
|
GaugeIcon,
|
||||||
|
Globe2Icon,
|
||||||
|
LineChartIcon,
|
||||||
|
type LucideIcon,
|
||||||
|
PieChartIcon,
|
||||||
|
UsersIcon,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
import { chartTypes } from '@openpanel/constants';
|
import { chartTypes } from '@openpanel/constants';
|
||||||
import { objectToZodEnums } from '@openpanel/validation';
|
import { objectToZodEnums } from '@openpanel/validation';
|
||||||
|
|
||||||
import { Combobox } from '../ui/combobox';
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import { cn } from '@/utils/cn';
|
||||||
|
import { Button } from '../ui/button';
|
||||||
import { changeChartType } from './reportSlice';
|
import { changeChartType } from './reportSlice';
|
||||||
|
|
||||||
interface ReportChartTypeProps {
|
interface ReportChartTypeProps {
|
||||||
@@ -13,20 +35,57 @@ interface ReportChartTypeProps {
|
|||||||
export function ReportChartType({ className }: ReportChartTypeProps) {
|
export function ReportChartType({ className }: ReportChartTypeProps) {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const type = useSelector((state) => state.report.chartType);
|
const type = useSelector((state) => state.report.chartType);
|
||||||
|
const items = objectToZodEnums(chartTypes).map((key) => ({
|
||||||
return (
|
|
||||||
<Combobox
|
|
||||||
icon={LineChartIcon}
|
|
||||||
className={className}
|
|
||||||
placeholder="Chart type"
|
|
||||||
onChange={(value) => {
|
|
||||||
dispatch(changeChartType(value));
|
|
||||||
}}
|
|
||||||
value={type}
|
|
||||||
items={objectToZodEnums(chartTypes).map((key) => ({
|
|
||||||
label: chartTypes[key],
|
label: chartTypes[key],
|
||||||
value: key,
|
value: key,
|
||||||
}))}
|
}));
|
||||||
/>
|
|
||||||
|
const Icons: Record<keyof typeof chartTypes, LucideIcon> = {
|
||||||
|
area: AreaChartIcon,
|
||||||
|
bar: ChartBarIcon,
|
||||||
|
pie: PieChartIcon,
|
||||||
|
funnel: ((props) => (
|
||||||
|
<ConeIcon className={cn('rotate-180', props.className)} />
|
||||||
|
)) as LucideIcon,
|
||||||
|
histogram: ChartColumnIncreasingIcon,
|
||||||
|
linear: LineChartIcon,
|
||||||
|
metric: GaugeIcon,
|
||||||
|
retention: UsersIcon,
|
||||||
|
map: Globe2Icon,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
icon={Icons[type]}
|
||||||
|
className={cn('justify-start', className)}
|
||||||
|
>
|
||||||
|
{items.find((item) => item.value === type)?.label}
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent className="w-56">
|
||||||
|
<DropdownMenuLabel>Available charts</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
|
<DropdownMenuGroup>
|
||||||
|
{items.map((item) => {
|
||||||
|
const Icon = Icons[item.value];
|
||||||
|
return (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={item.value}
|
||||||
|
onClick={() => dispatch(changeChartType(item.value))}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
<DropdownMenuShortcut>
|
||||||
|
<Icon className="size-4" />
|
||||||
|
</DropdownMenuShortcut>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</DropdownMenuGroup>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { TooltipPortal } from '@radix-ui/react-tooltip';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
|
||||||
|
|
||||||
interface TooltipCompleteProps {
|
interface TooltipCompleteProps {
|
||||||
@@ -15,12 +16,17 @@ export function TooltipComplete({
|
|||||||
}: TooltipCompleteProps) {
|
}: TooltipCompleteProps) {
|
||||||
return (
|
return (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild={typeof children !== 'string'}>
|
<TooltipTrigger
|
||||||
|
className="appearance-none"
|
||||||
|
style={{ textAlign: 'inherit' }}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
|
<TooltipPortal>
|
||||||
<TooltipContent side={side} disabled={disabled}>
|
<TooltipContent side={side} disabled={disabled}>
|
||||||
{content}
|
{content}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
|
</TooltipPortal>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,11 +30,7 @@ const Progress = React.forwardRef<
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{value && size !== 'sm' && (
|
{value && size !== 'sm' && (
|
||||||
<div
|
<div className="z-5 absolute bottom-0 top-0 flex items-center px-2 text-sm font-medium font-mono">
|
||||||
className={
|
|
||||||
'z-5 absolute bottom-0 top-0 flex items-center px-2 text-sm font-medium font-mono'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div>{round(value, 2)}%</div>
|
<div>{round(value, 2)}%</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -16,5 +16,6 @@ export * from './src/services/reference.service';
|
|||||||
export * from './src/services/id.service';
|
export * from './src/services/id.service';
|
||||||
export * from './src/services/retention.service';
|
export * from './src/services/retention.service';
|
||||||
export * from './src/services/notification.service';
|
export * from './src/services/notification.service';
|
||||||
|
export * from './src/services/funnel.service';
|
||||||
export * from './src/buffers';
|
export * from './src/buffers';
|
||||||
export * from './src/types';
|
export * from './src/types';
|
||||||
|
|||||||
@@ -245,30 +245,30 @@ export async function getEvents(
|
|||||||
): Promise<IServiceEvent[]> {
|
): Promise<IServiceEvent[]> {
|
||||||
const events = await chQuery<IClickhouseEvent>(sql);
|
const events = await chQuery<IClickhouseEvent>(sql);
|
||||||
const projectId = events[0]?.project_id;
|
const projectId = events[0]?.project_id;
|
||||||
if (options.profile && projectId) {
|
const [meta, profiles] = await Promise.all([
|
||||||
const ids = events.map((e) => e.profile_id);
|
options.meta && projectId
|
||||||
const profiles = await getProfiles(ids, projectId);
|
? db.eventMeta.findMany({
|
||||||
|
|
||||||
for (const event of events) {
|
|
||||||
event.profile = profiles.find((p) => p.id === event.profile_id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.meta && projectId) {
|
|
||||||
const names = uniq(events.map((e) => e.name));
|
|
||||||
const metas = await db.eventMeta.findMany({
|
|
||||||
where: {
|
where: {
|
||||||
name: {
|
name: {
|
||||||
in: names,
|
in: uniq(events.map((e) => e.name)),
|
||||||
},
|
},
|
||||||
projectId,
|
|
||||||
},
|
},
|
||||||
select: options.meta === true ? undefined : options.meta,
|
})
|
||||||
});
|
: null,
|
||||||
|
options.profile && projectId
|
||||||
|
? getProfiles(uniq(events.map((e) => e.profile_id)), projectId)
|
||||||
|
: null,
|
||||||
|
]);
|
||||||
|
|
||||||
for (const event of events) {
|
for (const event of events) {
|
||||||
event.meta = metas.find((m) => m.name === event.name);
|
if (profiles) {
|
||||||
|
event.profile = profiles.find((p) => p.id === event.profile_id);
|
||||||
|
}
|
||||||
|
if (meta) {
|
||||||
|
event.meta = meta.find((m) => m.name === event.name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return events.map(transformEvent);
|
return events.map(transformEvent);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -477,7 +477,7 @@ export async function getEventList({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (profileId) {
|
if (profileId) {
|
||||||
sb.where.deviceId = `(device_id IN (SELECT device_id as did FROM ${TABLE_NAMES.events} WHERE device_id != '' AND profile_id = ${escape(profileId)} group by did) OR profile_id = ${escape(profileId)})`;
|
sb.where.deviceId = `(device_id IN (SELECT device_id as did FROM ${TABLE_NAMES.events} WHERE project_id = ${escape(projectId)} AND device_id != '' AND profile_id = ${escape(profileId)} group by did) OR profile_id = ${escape(profileId)})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (startDate && endDate) {
|
if (startDate && endDate) {
|
||||||
|
|||||||
200
packages/db/src/services/funnel.service.ts
Normal file
200
packages/db/src/services/funnel.service.ts
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
import type { IChartEvent, IChartInput } from '@openpanel/validation';
|
||||||
|
import { escape } from 'sqlstring';
|
||||||
|
import {
|
||||||
|
TABLE_NAMES,
|
||||||
|
chQuery,
|
||||||
|
formatClickhouseDate,
|
||||||
|
} from '../clickhouse-client';
|
||||||
|
import { createSqlBuilder } from '../sql-builder';
|
||||||
|
import { getEventFiltersWhereClause } from './chart.service';
|
||||||
|
|
||||||
|
interface FunnelStep {
|
||||||
|
event: IChartEvent & { displayName: string };
|
||||||
|
count: number;
|
||||||
|
percent: number;
|
||||||
|
dropoffCount: number;
|
||||||
|
dropoffPercent: number;
|
||||||
|
previousCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FunnelResult {
|
||||||
|
totalSessions: number;
|
||||||
|
steps: FunnelStep[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawFunnelData {
|
||||||
|
level: number;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StepMetrics {
|
||||||
|
currentStep: number;
|
||||||
|
currentCount: number;
|
||||||
|
previousCount: number;
|
||||||
|
totalUsers: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main function
|
||||||
|
export async function getFunnelData({
|
||||||
|
projectId,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
...payload
|
||||||
|
}: IChartInput): Promise<FunnelResult> {
|
||||||
|
if (!startDate || !endDate) {
|
||||||
|
throw new Error('startDate and endDate are required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.events.length === 0) {
|
||||||
|
return { totalSessions: 0, steps: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const funnelWindow = (payload.funnelWindow || 24) * 3600;
|
||||||
|
const funnelGroup = payload.funnelGroup || 'session_id';
|
||||||
|
|
||||||
|
const sql = buildFunnelQuery(
|
||||||
|
payload.events,
|
||||||
|
projectId,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
funnelWindow,
|
||||||
|
funnelGroup,
|
||||||
|
);
|
||||||
|
|
||||||
|
return await chQuery<RawFunnelData>(sql)
|
||||||
|
.then((funnel) => fillFunnel(funnel, payload.events.length))
|
||||||
|
.then((funnel) => ({
|
||||||
|
totalSessions: funnel[0]?.count ?? 0,
|
||||||
|
steps: calculateStepMetrics(
|
||||||
|
funnel,
|
||||||
|
payload.events,
|
||||||
|
funnel[0]?.count ?? 0,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
function buildFunnelQuery(
|
||||||
|
events: IChartEvent[],
|
||||||
|
projectId: string,
|
||||||
|
startDate: string,
|
||||||
|
endDate: string,
|
||||||
|
funnelWindow: number,
|
||||||
|
funnelGroup: string,
|
||||||
|
): string {
|
||||||
|
const funnelConditions = 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
|
||||||
|
sp.${funnelGroup},
|
||||||
|
windowFunnel(${funnelWindow}, 'strict_increase')(
|
||||||
|
toUnixTimestamp(created_at),
|
||||||
|
${funnelConditions.join(', ')}
|
||||||
|
) AS level
|
||||||
|
FROM ${TABLE_NAMES.events}
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT
|
||||||
|
session_id,
|
||||||
|
any(profile_id) AS profile_id
|
||||||
|
FROM ${TABLE_NAMES.events}
|
||||||
|
WHERE project_id = ${escape(projectId)}
|
||||||
|
AND created_at >= '${formatClickhouseDate(startDate)}'
|
||||||
|
AND created_at <= '${formatClickhouseDate(endDate)}'
|
||||||
|
GROUP BY session_id
|
||||||
|
HAVING profile_id IS NOT NULL
|
||||||
|
) AS sp ON session_id = sp.session_id
|
||||||
|
WHERE
|
||||||
|
project_id = ${escape(projectId)} AND
|
||||||
|
created_at >= '${formatClickhouseDate(startDate)}' AND
|
||||||
|
created_at <= '${formatClickhouseDate(endDate)}' AND
|
||||||
|
name IN (${events.map((event) => escape(event.name)).join(', ')})
|
||||||
|
GROUP BY sp.${funnelGroup}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const sql = `
|
||||||
|
SELECT
|
||||||
|
level,
|
||||||
|
count() AS count
|
||||||
|
FROM (${innerSql})
|
||||||
|
WHERE level != 0
|
||||||
|
GROUP BY level
|
||||||
|
ORDER BY level DESC`;
|
||||||
|
|
||||||
|
return sql;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateStepMetrics(
|
||||||
|
funnelData: RawFunnelData[],
|
||||||
|
events: IChartEvent[],
|
||||||
|
totalSessions: number,
|
||||||
|
): FunnelStep[] {
|
||||||
|
return funnelData
|
||||||
|
.sort((a, b) => a.level - b.level) // Ensure steps are in order
|
||||||
|
.map((data, index, array): FunnelStep => {
|
||||||
|
const metrics: StepMetrics = {
|
||||||
|
currentStep: data.level,
|
||||||
|
currentCount: data.count,
|
||||||
|
previousCount: index === 0 ? totalSessions : array[index - 1]!.count,
|
||||||
|
totalUsers: totalSessions,
|
||||||
|
};
|
||||||
|
|
||||||
|
const event = events[data.level - 1]!;
|
||||||
|
|
||||||
|
return {
|
||||||
|
event: {
|
||||||
|
...event,
|
||||||
|
displayName: event.displayName ?? event.name,
|
||||||
|
},
|
||||||
|
count: metrics.currentCount,
|
||||||
|
percent: calculatePercent(metrics.currentCount, metrics.totalUsers),
|
||||||
|
dropoffCount: calculateDropoff(metrics),
|
||||||
|
dropoffPercent: calculateDropoffPercent(metrics),
|
||||||
|
previousCount: metrics.previousCount,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculatePercent(count: number, total: number): number {
|
||||||
|
return (count / total) * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateDropoff({
|
||||||
|
currentCount,
|
||||||
|
previousCount,
|
||||||
|
}: StepMetrics): number {
|
||||||
|
return previousCount - currentCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateDropoffPercent({
|
||||||
|
currentCount,
|
||||||
|
previousCount,
|
||||||
|
}: StepMetrics): number {
|
||||||
|
return 100 - (currentCount / previousCount) * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillFunnel(funnel: RawFunnelData[], steps: number): RawFunnelData[] {
|
||||||
|
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 (prevStep) {
|
||||||
|
step.count += prevStep.count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return filled;
|
||||||
|
}
|
||||||
@@ -13,9 +13,9 @@ import {
|
|||||||
subYears,
|
subYears,
|
||||||
} from 'date-fns';
|
} from 'date-fns';
|
||||||
import * as mathjs from 'mathjs';
|
import * as mathjs from 'mathjs';
|
||||||
import { last, pluck, repeat, reverse, uniq } from 'ramda';
|
import { pluck, uniq } from 'ramda';
|
||||||
import { escape } from 'sqlstring';
|
|
||||||
|
|
||||||
|
import type { ISerieDataItem } from '@openpanel/common';
|
||||||
import {
|
import {
|
||||||
average,
|
average,
|
||||||
completeSerie,
|
completeSerie,
|
||||||
@@ -26,17 +26,8 @@ import {
|
|||||||
slug,
|
slug,
|
||||||
sum,
|
sum,
|
||||||
} from '@openpanel/common';
|
} from '@openpanel/common';
|
||||||
import type { ISerieDataItem } from '@openpanel/common';
|
|
||||||
import { alphabetIds } from '@openpanel/constants';
|
import { alphabetIds } from '@openpanel/constants';
|
||||||
import {
|
import { chQuery, getChartSql } from '@openpanel/db';
|
||||||
TABLE_NAMES,
|
|
||||||
chQuery,
|
|
||||||
createSqlBuilder,
|
|
||||||
formatClickhouseDate,
|
|
||||||
getChartSql,
|
|
||||||
getEventFiltersWhereClause,
|
|
||||||
getProfiles,
|
|
||||||
} from '@openpanel/db';
|
|
||||||
import type {
|
import type {
|
||||||
FinalChart,
|
FinalChart,
|
||||||
IChartEvent,
|
IChartEvent,
|
||||||
@@ -241,28 +232,6 @@ export function getDatesFromRange(range: IChartRange) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function 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();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getChartStartEndDate({
|
export function getChartStartEndDate({
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
@@ -288,147 +257,6 @@ export function getChartPrevStartEndDate({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getFunnelData({
|
|
||||||
projectId,
|
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
...payload
|
|
||||||
}: IChartInput) {
|
|
||||||
const funnelWindow = (payload.funnelWindow || 24) * 3600;
|
|
||||||
const funnelGroup = payload.funnelGroup || 'session_id';
|
|
||||||
|
|
||||||
if (!startDate || !endDate) {
|
|
||||||
throw new Error('startDate and endDate are required');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (payload.events.length === 0) {
|
|
||||||
return {
|
|
||||||
totalSessions: 0,
|
|
||||||
steps: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
${funnelGroup},
|
|
||||||
windowFunnel(${funnelWindow}, 'strict_increase')(toUnixTimestamp(created_at), ${funnels.join(', ')}) AS level
|
|
||||||
FROM ${TABLE_NAMES.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 ${funnelGroup}`;
|
|
||||||
|
|
||||||
const sql = `SELECT level, count() AS count FROM (${innerSql}) WHERE level != 0 GROUP BY level ORDER BY level DESC`;
|
|
||||||
|
|
||||||
const funnel = await chQuery<{ level: number; count: number }>(sql);
|
|
||||||
const maxLevel = payload.events.length;
|
|
||||||
const filledFunnelRes = fillFunnel(funnel, maxLevel);
|
|
||||||
|
|
||||||
const totalSessions = last(filledFunnelRes)?.count ?? 0;
|
|
||||||
const steps = reverse(filledFunnelRes).reduce(
|
|
||||||
(acc, item, index, list) => {
|
|
||||||
const prev = list[index - 1] ?? { count: totalSessions };
|
|
||||||
const event = payload.events[item.level - 1]!;
|
|
||||||
return [
|
|
||||||
...acc,
|
|
||||||
{
|
|
||||||
event: {
|
|
||||||
...event,
|
|
||||||
displayName: event.displayName ?? event.name,
|
|
||||||
},
|
|
||||||
count: item.count,
|
|
||||||
percent: (item.count / totalSessions) * 100,
|
|
||||||
dropoffCount: prev.count - item.count,
|
|
||||||
dropoffPercent: 100 - (item.count / prev.count) * 100,
|
|
||||||
previousCount: prev.count,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
},
|
|
||||||
[] as {
|
|
||||||
event: IChartEvent & { displayName: string };
|
|
||||||
count: number;
|
|
||||||
percent: number;
|
|
||||||
dropoffCount: number;
|
|
||||||
dropoffPercent: number;
|
|
||||||
previousCount: number;
|
|
||||||
}[],
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
totalSessions,
|
|
||||||
steps,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getFunnelStep({
|
|
||||||
projectId,
|
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
step,
|
|
||||||
...payload
|
|
||||||
}: IChartInput & {
|
|
||||||
step: number;
|
|
||||||
}) {
|
|
||||||
throw new Error('not implemented');
|
|
||||||
// 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 ${TABLE_NAMES.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 ${TABLE_NAMES.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(
|
|
||||||
// res.map((r) => r.id),
|
|
||||||
// projectId,
|
|
||||||
// );
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getChartSerie(payload: IGetChartDataInput) {
|
export async function getChartSerie(payload: IGetChartDataInput) {
|
||||||
async function getSeries() {
|
async function getSeries() {
|
||||||
const result = await chQuery<ISerieDataItem>(getChartSql(payload));
|
const result = await chQuery<ISerieDataItem>(getChartSql(payload));
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
chQuery,
|
chQuery,
|
||||||
createSqlBuilder,
|
createSqlBuilder,
|
||||||
db,
|
db,
|
||||||
|
getFunnelData,
|
||||||
getSelectPropertyKey,
|
getSelectPropertyKey,
|
||||||
toDate,
|
toDate,
|
||||||
} from '@openpanel/db';
|
} from '@openpanel/db';
|
||||||
@@ -31,8 +32,6 @@ import {
|
|||||||
getChart,
|
getChart,
|
||||||
getChartPrevStartEndDate,
|
getChartPrevStartEndDate,
|
||||||
getChartStartEndDate,
|
getChartStartEndDate,
|
||||||
getFunnelData,
|
|
||||||
getFunnelStep,
|
|
||||||
} from './chart.helpers';
|
} from './chart.helpers';
|
||||||
|
|
||||||
function utc(date: string | Date) {
|
function utc(date: string | Date) {
|
||||||
@@ -87,9 +86,12 @@ export const chartRouter = createTRPCRouter({
|
|||||||
.map((item) => item.replace(/\.([0-9]+)/g, '[*]'))
|
.map((item) => item.replace(/\.([0-9]+)/g, '[*]'))
|
||||||
.map((item) => `properties.${item}`);
|
.map((item) => `properties.${item}`);
|
||||||
|
|
||||||
|
if (event === '*') {
|
||||||
|
properties.push('name');
|
||||||
|
}
|
||||||
|
|
||||||
properties.push(
|
properties.push(
|
||||||
'has_profile',
|
'has_profile',
|
||||||
'name',
|
|
||||||
'path',
|
'path',
|
||||||
'origin',
|
'origin',
|
||||||
'referrer',
|
'referrer',
|
||||||
@@ -184,7 +186,9 @@ export const chartRouter = createTRPCRouter({
|
|||||||
|
|
||||||
const [current, previous] = await Promise.all([
|
const [current, previous] = await Promise.all([
|
||||||
getFunnelData({ ...input, ...currentPeriod }),
|
getFunnelData({ ...input, ...currentPeriod }),
|
||||||
getFunnelData({ ...input, ...previousPeriod }),
|
input.previous
|
||||||
|
? getFunnelData({ ...input, ...previousPeriod })
|
||||||
|
: Promise.resolve(null),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -193,17 +197,6 @@ export const chartRouter = createTRPCRouter({
|
|||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
funnelStep: protectedProcedure
|
|
||||||
.input(
|
|
||||||
zChartInput.extend({
|
|
||||||
step: z.number(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.query(async ({ input }) => {
|
|
||||||
const currentPeriod = getChartStartEndDate(input);
|
|
||||||
return getFunnelStep({ ...input, ...currentPeriod });
|
|
||||||
}),
|
|
||||||
|
|
||||||
chart: publicProcedure.input(zChartInput).query(async ({ input, ctx }) => {
|
chart: publicProcedure.input(zChartInput).query(async ({ input, ctx }) => {
|
||||||
if (ctx.session.userId) {
|
if (ctx.session.userId) {
|
||||||
const access = await getProjectAccessCached({
|
const access = await getProjectAccessCached({
|
||||||
|
|||||||
Reference in New Issue
Block a user