add funnels

This commit is contained in:
Carl-Gerhard Lindesvärd
2024-02-24 07:22:39 +01:00
parent 9c92803c4c
commit 15388882be
34 changed files with 916 additions and 131 deletions

View File

@@ -2,7 +2,7 @@
import { WidgetHead } from '@/components/overview/overview-widget';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { Chart } from '@/components/report/chart';
import { ChartSwitch } from '@/components/report/chart';
import { Widget, WidgetBody } from '@/components/Widget';
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
import { cn } from '@/utils/cn';
@@ -186,7 +186,7 @@ export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
setMetric(index);
}}
>
<Chart hideID {...report} />
<ChartSwitch hideID {...report} />
<div
className={cn(
'transition-opacity top-0 left-0 right-0 bottom-0 absolute rounded-md w-full h-full border ring-1 border-chart-0 ring-chart-0',
@@ -201,7 +201,7 @@ export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
<div className="title">{selectedMetric.events[0]?.displayName}</div>
</WidgetHead>
<WidgetBody>
<Chart
<ChartSwitch
key={selectedMetric.id}
hideID
{...selectedMetric}

View File

@@ -3,7 +3,7 @@ import { ListProperties } from '@/components/events/ListProperties';
import { OverviewFiltersButtons } from '@/components/overview/filters/overview-filters-buttons';
import { OverviewFiltersDrawer } from '@/components/overview/filters/overview-filters-drawer';
import { ProfileAvatar } from '@/components/profiles/ProfileAvatar';
import { Chart } from '@/components/report/chart';
import { ChartSwitch } from '@/components/report/chart';
import { GradientBackground } from '@/components/ui/gradient-background';
import { KeyValue } from '@/components/ui/key-value';
import { Widget, WidgetBody, WidgetHead } from '@/components/Widget';
@@ -162,7 +162,7 @@ export default async function Page({
<span className="title">Events per day</span>
</WidgetHead>
<WidgetBody className="flex gap-2">
<Chart {...profileChart} />
<ChartSwitch {...profileChart} />
</WidgetBody>
</Widget>
</div>

View File

@@ -2,7 +2,7 @@
import { useEffect } from 'react';
import { StickyBelowHeader } from '@/app/(app)/[organizationId]/[projectId]/layout-sticky-below-header';
import { Chart } from '@/components/report/chart';
import { ChartSwitch } from '@/components/report/chart';
import { ReportChartType } from '@/components/report/ReportChartType';
import { ReportInterval } from '@/components/report/ReportInterval';
import { ReportLineType } from '@/components/report/ReportLineType';
@@ -74,7 +74,9 @@ export default function ReportEditor({
</div>
</StickyBelowHeader>
<div className="flex flex-col gap-4 p-4">
{report.ready && <Chart {...report} projectId={projectId} editMode />}
{report.ready && (
<ChartSwitch {...report} projectId={projectId} editMode />
)}
</div>
<SheetContent className="!max-w-lg w-full" side="left">
<ReportSidebar />

View File

@@ -0,0 +1,21 @@
import { Funnel } from '@/components/report/funnel';
import PageLayout from '../page-layout';
export const metadata = {
title: 'Funnel - Openpanel.dev',
};
interface PageProps {
params: {
organizationId: string;
};
}
export default function Page({ params: { organizationId } }: PageProps) {
return (
<PageLayout title="Funnel" organizationSlug={organizationId}>
<Funnel />
</PageLayout>
);
}

View File

@@ -6,7 +6,7 @@ import AnimateHeight from 'react-animate-height';
import type { IChartInput } from '@mixan/validation';
import { Chart } from '../report/chart';
import { ChartSwitch } from '../report/chart';
import { Widget, WidgetBody, WidgetHead } from '../Widget';
import { useOverviewOptions } from './useOverviewOptions';
@@ -61,7 +61,7 @@ export function OverviewLiveHistogram({
<AnimateHeight duration={500} height={liveHistogram ? 'auto' : 0}>
<WidgetBody>
<Chart {...report} />
<ChartSwitch {...report} />
</WidgetBody>
</AnimateHeight>
</Widget>

View File

@@ -1,6 +1,6 @@
'use client';
import { Chart } from '@/components/report/chart';
import { ChartSwitch } from '@/components/report/chart';
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
import { cn } from '@/utils/cn';
@@ -178,7 +178,7 @@ export default function OverviewTopDevices({
</WidgetButtons>
</WidgetHead>
<WidgetBody>
<Chart
<ChartSwitch
hideID
{...widget.chart}
previous={false}

View File

@@ -1,6 +1,6 @@
'use client';
import { Chart } from '@/components/report/chart';
import { ChartSwitch } from '@/components/report/chart';
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
import { cn } from '@/utils/cn';
@@ -74,7 +74,7 @@ export default function OverviewTopEvents({
</WidgetButtons>
</WidgetHead>
<WidgetBody>
<Chart hideID {...widget.chart} previous={false} />
<ChartSwitch hideID {...widget.chart} previous={false} />
</WidgetBody>
</Widget>
</>

View File

@@ -1,6 +1,6 @@
'use client';
import { Chart } from '@/components/report/chart';
import { ChartSwitch } from '@/components/report/chart';
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
import { cn } from '@/utils/cn';
@@ -148,7 +148,7 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
</WidgetButtons>
</WidgetHead>
<WidgetBody>
<Chart
<ChartSwitch
hideID
{...widget.chart}
previous={false}

View File

@@ -1,6 +1,6 @@
'use client';
import { Chart } from '@/components/report/chart';
import { ChartSwitch } from '@/components/report/chart';
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
import { cn } from '@/utils/cn';
@@ -120,7 +120,7 @@ export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) {
</WidgetButtons>
</WidgetHead>
<WidgetBody>
<Chart
<ChartSwitch
hideID
{...widget.chart}
previous={false}

View File

@@ -1,6 +1,6 @@
'use client';
import { Chart } from '@/components/report/chart';
import { ChartSwitch } from '@/components/report/chart';
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
import { cn } from '@/utils/cn';
@@ -262,7 +262,7 @@ export default function OverviewTopSources({
</WidgetButtons>
</WidgetHead>
<WidgetBody>
<Chart
<ChartSwitch
hideID
{...widget.chart}
previous={false}

View File

@@ -0,0 +1,92 @@
'use client';
import { api } from '@/app/_trpc/client';
import type { IChartInput } from '@mixan/validation';
import { ChartEmpty } from './ChartEmpty';
import { ReportAreaChart } from './ReportAreaChart';
import { ReportBarChart } from './ReportBarChart';
import { ReportHistogramChart } from './ReportHistogramChart';
import { ReportLineChart } from './ReportLineChart';
import { ReportMapChart } from './ReportMapChart';
import { ReportMetricChart } from './ReportMetricChart';
import { ReportPieChart } from './ReportPieChart';
export type ReportChartProps = IChartInput;
export function Chart({
interval,
events,
breakdowns,
chartType,
name,
range,
lineType,
previous,
formula,
unit,
metric,
projectId,
}: ReportChartProps) {
const [data] = api.chart.chart.useSuspenseQuery(
{
// dont send lineType since it does not need to be sent
lineType: 'monotone',
interval,
chartType,
events,
breakdowns,
name,
range,
startDate: null,
endDate: null,
projectId,
previous,
formula,
unit,
metric,
},
{
keepPreviousData: true,
}
);
if (data.series.length === 0) {
return <ChartEmpty />;
}
if (chartType === 'map') {
return <ReportMapChart data={data} />;
}
if (chartType === 'histogram') {
return <ReportHistogramChart interval={interval} data={data} />;
}
if (chartType === 'bar') {
return <ReportBarChart data={data} />;
}
if (chartType === 'metric') {
return <ReportMetricChart data={data} />;
}
if (chartType === 'pie') {
return <ReportPieChart data={data} />;
}
if (chartType === 'linear') {
return (
<ReportLineChart lineType={lineType} interval={interval} data={data} />
);
}
if (chartType === 'area') {
return (
<ReportAreaChart lineType={lineType} interval={interval} data={data} />
);
}
return <p>Unknown chart type</p>;
}

View File

@@ -1,10 +1,10 @@
'use client';
import React, { Suspense, useEffect, useRef } from 'react';
import React, { useEffect, useRef } from 'react';
import { useInViewport } from 'react-in-viewport';
import type { ReportChartProps } from '.';
import { Chart } from '.';
import { ChartSwitch } from '.';
import { ChartLoading } from './ChartLoading';
import type { ChartContextType } from './ChartProvider';
@@ -24,7 +24,7 @@ export function LazyChart(props: ReportChartProps & ChartContextType) {
return (
<div ref={ref}>
{once.current || inViewport ? (
<Chart {...props} editMode={false} />
<ChartSwitch {...props} editMode={false} />
) : (
<ChartLoading />
)}

View File

@@ -62,7 +62,7 @@ export function MetricCard({
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 font-medium">
<ColorSquare>{serie.event.id}</ColorSquare>
{serie.name ?? serie.event.displayName ?? serie.event.name}
{serie.name || serie.event.displayName || serie.event.name}
</div>
<PreviousDiffIndicator {...serie.metrics.previous[metric]} />
</div>

View File

@@ -1,98 +1,19 @@
'use client';
import { memo, useEffect, useState } from 'react';
import type { RouterOutputs } from '@/app/_trpc/client';
import { api } from '@/app/_trpc/client';
import type { IChartInput } from '@mixan/validation';
import { ChartEmpty } from './ChartEmpty';
import { ChartLoading } from './ChartLoading';
import { Funnel } from '../funnel';
import { Chart } from './Chart';
import { withChartProivder } from './ChartProvider';
import { ReportAreaChart } from './ReportAreaChart';
import { ReportBarChart } from './ReportBarChart';
import { ReportHistogramChart } from './ReportHistogramChart';
import { ReportLineChart } from './ReportLineChart';
import { ReportMapChart } from './ReportMapChart';
import { ReportMetricChart } from './ReportMetricChart';
import { ReportPieChart } from './ReportPieChart';
export type ReportChartProps = IChartInput & {
initialData?: RouterOutputs['chart']['chart'];
};
export type ReportChartProps = IChartInput;
export const Chart = withChartProivder(function Chart({
interval,
events,
breakdowns,
chartType,
name,
range,
lineType,
previous,
formula,
unit,
metric,
projectId,
}: ReportChartProps) {
const [data] = api.chart.chart.useSuspenseQuery(
{
// dont send lineType since it does not need to be sent
lineType: 'monotone',
interval,
chartType,
events,
breakdowns,
name,
range,
startDate: null,
endDate: null,
projectId,
previous,
formula,
unit,
metric,
},
{
keepPreviousData: true,
}
);
if (data.series.length === 0) {
return <ChartEmpty />;
export const ChartSwitch = withChartProivder(function ChartSwitch(
props: ReportChartProps
) {
if (props.chartType === 'funnel') {
return <Funnel {...props} />;
}
if (chartType === 'map') {
return <ReportMapChart data={data} />;
}
if (chartType === 'histogram') {
return <ReportHistogramChart interval={interval} data={data} />;
}
if (chartType === 'bar') {
return <ReportBarChart data={data} />;
}
if (chartType === 'metric') {
return <ReportMetricChart data={data} />;
}
if (chartType === 'pie') {
return <ReportPieChart data={data} />;
}
if (chartType === 'linear') {
return (
<ReportLineChart lineType={lineType} interval={interval} data={data} />
);
}
if (chartType === 'area') {
return (
<ReportAreaChart lineType={lineType} interval={interval} data={data} />
);
}
return <p>Unknown chart type</p>;
return <Chart {...props} />;
});

View File

@@ -0,0 +1,169 @@
'use client';
import type { RouterOutputs } from '@/app/_trpc/client';
import {
Carousel,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
} from '@/components/ui/carousel';
import { cn } from '@/utils/cn';
import { round } from '@/utils/math';
import { ArrowRight, ArrowRightIcon } from 'lucide-react';
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({
steps,
totalSessions,
}: RouterOutputs['chart']['funnel']) {
return (
<Carousel className="w-full py-4" opts={{ loop: false, dragFree: true }}>
<CarouselContent>
<CarouselItem className={'flex-[0_0_0px] pl-3'} />
{steps.map((step, index, list) => {
const finalStep = index === list.length - 1;
return (
<CarouselItem
className={'flex-[0_0_320px] max-w-full p-0 px-1'}
key={step.event.id}
>
<div className="border border-border divide-y divide-border bg-white">
<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="aspect-square relative">
<FunnelChart from={step.prevPercent} to={step.percent} />
<div className="absolute top-0 left-0 right-0 p-4 flex flex-col bg-white/40">
<div className="uppercase font-medium text-muted-foreground">
Sessions
</div>
<div className="uppercase text-3xl font-bold flex items-center">
<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('p-4 flex flex-col items-center')}>
<div className="uppercase text-xs font-medium">
Conversion
</div>
<div
className={cn(
'uppercase text-3xl font-bold',
getDropoffColor(step.dropoff.percent)
)}
>
{round(step.percent, 1)}%
</div>
<div className="uppercase text-sm mt-0 font-medium text-muted-foreground">
Converted {step.current} of {totalSessions} sessions
</div>
</div>
) : (
<div className={cn('p-4 flex flex-col items-center')}>
<div className="uppercase text-xs font-medium">Dropoff</div>
<div
className={cn(
'uppercase text-3xl font-bold',
getDropoffColor(step.dropoff.percent)
)}
>
{round(step.dropoff.percent, 1)}%
</div>
<div className="uppercase text-sm mt-0 font-medium 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

@@ -0,0 +1,53 @@
'use client';
import type { RouterOutputs } from '@/app/_trpc/client';
import { api } from '@/app/_trpc/client';
import type { IChartInput } from '@mixan/validation';
import { ChartEmpty } from '../chart/ChartEmpty';
import { withChartProivder } from '../chart/ChartProvider';
import { FunnelSteps } from './Funnel';
export type ReportChartProps = IChartInput & {
initialData?: RouterOutputs['chart']['funnel'];
};
export const Funnel = withChartProivder(function Chart({
events,
name,
range,
projectId,
}: ReportChartProps) {
const [data] = api.chart.funnel.useSuspenseQuery(
{
events,
name,
range,
projectId,
lineType: 'monotone',
interval: 'day',
chartType: 'funnel',
breakdowns: [],
startDate: null,
endDate: null,
previous: false,
formula: undefined,
unit: undefined,
metric: 'sum',
},
{
keepPreviousData: true,
}
);
if (data.steps.length === 0) {
return <ChartEmpty />;
}
return (
<div className="-mx-4">
<FunnelSteps {...data} />
</div>
);
});

View File

@@ -1,16 +1,20 @@
import { Button } from '@/components/ui/button';
import { SheetClose } from '@/components/ui/sheet';
import { useSelector } from '@/redux';
import { ReportBreakdowns } from './ReportBreakdowns';
import { ReportEvents } from './ReportEvents';
import { ReportForumula } from './ReportForumula';
export function ReportSidebar() {
const { chartType } = useSelector((state) => state.report);
const showForumula = chartType !== 'funnel';
const showBreakdown = chartType !== 'funnel';
return (
<div className="flex flex-col gap-8 pb-12">
<ReportEvents />
<ReportForumula />
<ReportBreakdowns />
{showForumula && <ReportForumula />}
{showBreakdown && <ReportBreakdowns />}
<div className="absolute bottom-0 left-0 right-0 p-6 bg-gradient-to-t from-white/100 to-white/0">
<SheetClose asChild>
<Button className="w-full">Done</Button>

View File

@@ -0,0 +1,258 @@
import * as React from 'react';
import { Button } from '@/components/ui/button';
import { cn } from '@/utils/cn';
import useEmblaCarousel from 'embla-carousel-react';
import type { UseEmblaCarouselType } from 'embla-carousel-react';
import { ArrowLeft, ArrowRight } from 'lucide-react';
type CarouselApi = UseEmblaCarouselType[1];
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
type CarouselOptions = UseCarouselParameters[0];
type CarouselPlugin = UseCarouselParameters[1];
interface CarouselProps {
opts?: CarouselOptions;
plugins?: CarouselPlugin;
orientation?: 'horizontal' | 'vertical';
setApi?: (api: CarouselApi) => void;
}
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
api: ReturnType<typeof useEmblaCarousel>[1];
scrollPrev: () => void;
scrollNext: () => void;
canScrollPrev: boolean;
canScrollNext: boolean;
} & CarouselProps;
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
function useCarousel() {
const context = React.useContext(CarouselContext);
if (!context) {
throw new Error('useCarousel must be used within a <Carousel />');
}
return context;
}
const Carousel = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & CarouselProps
>(
(
{
orientation = 'horizontal',
opts,
setApi,
plugins,
className,
children,
...props
},
ref
) => {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === 'horizontal' ? 'x' : 'y',
},
plugins
);
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
const [canScrollNext, setCanScrollNext] = React.useState(false);
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) {
return;
}
setCanScrollPrev(api.canScrollPrev());
setCanScrollNext(api.canScrollNext());
}, []);
const scrollPrev = React.useCallback(() => {
api?.scrollPrev();
}, [api]);
const scrollNext = React.useCallback(() => {
api?.scrollNext();
}, [api]);
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === 'ArrowLeft') {
event.preventDefault();
scrollPrev();
} else if (event.key === 'ArrowRight') {
event.preventDefault();
scrollNext();
}
},
[scrollPrev, scrollNext]
);
React.useEffect(() => {
if (!api || !setApi) {
return;
}
setApi(api);
}, [api, setApi]);
React.useEffect(() => {
if (!api) {
return;
}
onSelect(api);
api.on('reInit', onSelect);
api.on('select', onSelect);
return () => {
api?.off('select', onSelect);
};
}, [api, onSelect]);
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === 'y' ? 'vertical' : 'horizontal'),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
ref={ref}
onKeyDownCapture={handleKeyDown}
className={cn('relative', className)}
role="region"
aria-roledescription="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
);
}
);
Carousel.displayName = 'Carousel';
const CarouselContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { carouselRef, orientation } = useCarousel();
return (
<div ref={carouselRef} className="overflow-hidden">
<div
ref={ref}
className={cn(
'flex',
orientation === 'horizontal' ? '' : 'flex-col',
className
)}
{...props}
/>
</div>
);
});
CarouselContent.displayName = 'CarouselContent';
const CarouselItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { orientation } = useCarousel();
return (
<div
ref={ref}
role="group"
aria-roledescription="slide"
className={cn(
'min-w-0 shrink-0 grow-0 basis-full',
orientation === 'horizontal' ? 'pl-4' : 'pt-4',
className
)}
{...props}
/>
);
});
CarouselItem.displayName = 'CarouselItem';
const CarouselPrevious = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = 'outline', size = 'icon', ...props }, ref) => {
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
'absolute h-10 w-10 rounded-full',
orientation === 'horizontal'
? 'left-6 top-1/2 -translate-y-1/2'
: '-top-12 left-1/2 -translate-x-1/2 rotate-90',
className
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft className="h-4 w-4" />
<span className="sr-only">Previous slide</span>
</Button>
);
});
CarouselPrevious.displayName = 'CarouselPrevious';
const CarouselNext = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = 'outline', size = 'icon', ...props }, ref) => {
const { orientation, scrollNext, canScrollNext } = useCarousel();
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
'absolute h-10 w-10 rounded-full',
orientation === 'horizontal'
? 'right-6 top-1/2 -translate-y-1/2'
: '-bottom-12 left-1/2 -translate-x-1/2 rotate-90',
className
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight className="h-4 w-4" />
<span className="sr-only">Next slide</span>
</Button>
);
});
CarouselNext.displayName = 'CarouselNext';
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
};

View File

@@ -5,7 +5,7 @@ import {
} from '@/server/api/trpc';
import { getDaysOldDate } from '@/utils/date';
import { average, max, min, round, sum } from '@/utils/math';
import { flatten, map, pipe, prop, sort, uniq } from 'ramda';
import { flatten, map, pipe, prop, repeat, reverse, sort, uniq } from 'ramda';
import { z } from 'zod';
import { chQuery, createSqlBuilder } from '@mixan/db';
@@ -14,6 +14,116 @@ import type { IChartEvent, IChartInput, IChartRange } from '@mixan/validation';
import { getChartData, withFormula } from './chart.helpers';
async function getFunnelData(payload: IChartInput) {
if (payload.events.length === 0) {
return {
totalSessions: 0,
steps: [],
};
}
const sql = `SELECT
level,
count() AS count
FROM
(
SELECT
session_id,
windowFunnel(6048000000000000,'strict_increase')(toUnixTimestamp(created_at), ${payload.events.map((event) => `name = '${event.name}'`).join(', ')}) AS level
FROM events
WHERE (created_at >= '2024-02-24') AND (created_at <= '2024-02-25')
GROUP BY session_id
)
GROUP BY level
ORDER BY level DESC;
`;
const [funnelRes, sessionRes] = await Promise.all([
chQuery<{ level: number; count: number }>(sql),
chQuery<{ count: number }>(
`SELECT count(name) as count FROM events WHERE name = 'session_start' AND (created_at >= '2024-02-24') AND (created_at <= '2024-02-25')`
),
]);
console.log('Funnel SQL: ', sql);
if (funnelRes[0]?.level !== payload.events.length) {
funnelRes.unshift({
level: payload.events.length,
count: 0,
});
}
const totalSessions = sessionRes[0]?.count ?? 0;
const filledFunnelRes = funnelRes.reduce(
(acc, item, index) => {
const diff =
index !== 0 ? (acc[acc.length - 1]?.level ?? 0) - item.level : 1;
if (diff > 1) {
acc.push(
...reverse(
repeat({}, diff - 1).map((_, index) => ({
count: acc[acc.length - 1]?.count ?? 0,
level: item.level + index + 1,
}))
)
);
}
return [
...acc,
{
count: item.count + (acc[acc.length - 1]?.count ?? 0),
level: item.level,
},
];
},
[] as typeof funnelRes
);
const steps = reverse(filledFunnelRes)
.filter((item) => item.level !== 0)
.reduce(
(acc, item, index, list) => {
const prev = list[index - 1] ?? { count: totalSessions };
return [
...acc,
{
event: payload.events[item.level - 1]!,
before: prev.count,
current: item.count,
dropoff: {
bajs: {
prev,
item,
},
count: prev.count - item.count,
percent: 100 - (item.count / prev.count) * 100,
},
percent: (item.count / totalSessions) * 100,
prevPercent: (prev.count / totalSessions) * 100,
},
];
},
[] as {
event: IChartEvent;
before: number;
current: number;
dropoff: {
count: number;
percent: number;
};
percent: number;
prevPercent: number;
}[]
);
return {
totalSessions,
steps,
};
}
type PreviousValue = {
value: number;
diff: number | null;
@@ -144,6 +254,10 @@ export const chartRouter = createTRPCRouter({
};
}),
funnel: publicProcedure.input(zChartInput).query(async ({ input }) => {
return getFunnelData(input);
}),
// TODO: Make this private
chart: publicProcedure.input(zChartInput).query(async ({ input }) => {
const current = getDatesFromRange(input.range);