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

@@ -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>