feature(dashboard): add conversion rate graph
This commit is contained in:
@@ -9,7 +9,7 @@ export function createChartTooltip<
|
|||||||
Tooltip: React.ComponentType<
|
Tooltip: React.ComponentType<
|
||||||
{
|
{
|
||||||
context: PropsFromContext;
|
context: PropsFromContext;
|
||||||
data: PropsFromTooltip;
|
data: PropsFromTooltip[];
|
||||||
} & TooltipProps<number, string>
|
} & TooltipProps<number, string>
|
||||||
>,
|
>,
|
||||||
) {
|
) {
|
||||||
@@ -24,7 +24,7 @@ export function createChartTooltip<
|
|||||||
|
|
||||||
const InnerTooltip = (tooltip: TooltipProps<number, string>) => {
|
const InnerTooltip = (tooltip: TooltipProps<number, string>) => {
|
||||||
const context = useContext();
|
const context = useContext();
|
||||||
const data = tooltip.payload?.[0]?.payload;
|
const data = tooltip.payload?.map((p) => p.payload) ?? [];
|
||||||
|
|
||||||
if (!data || !tooltip.active) {
|
if (!data || !tooltip.active) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export function ReportTable({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Stats className="my-4">
|
<Stats className="my-4 grid grid-cols-1 @xl:grid-cols-3 @4xl:grid-cols-6">
|
||||||
<StatsCard title="Total" value={number.format(data.metrics.sum)} />
|
<StatsCard title="Total" value={number.format(data.metrics.sum)} />
|
||||||
<StatsCard
|
<StatsCard
|
||||||
title="Average"
|
title="Average"
|
||||||
|
|||||||
183
apps/dashboard/src/components/report-chart/conversion/chart.tsx
Normal file
183
apps/dashboard/src/components/report-chart/conversion/chart.tsx
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type { RouterOutputs } from '@/trpc/client';
|
||||||
|
import { api } from '@/trpc/client';
|
||||||
|
import { cn } from '@/utils/cn';
|
||||||
|
import { getChartColor } from '@/utils/theme';
|
||||||
|
import {
|
||||||
|
CartesianGrid,
|
||||||
|
Line,
|
||||||
|
LineChart,
|
||||||
|
ReferenceLine,
|
||||||
|
ResponsiveContainer,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from 'recharts';
|
||||||
|
|
||||||
|
import { createChartTooltip } from '@/components/charts/chart-tooltip';
|
||||||
|
import { useFormatDateInterval } from '@/hooks/useFormatDateInterval';
|
||||||
|
import { useNumber } from '@/hooks/useNumerFormatter';
|
||||||
|
import { getPreviousMetric } from '@openpanel/common';
|
||||||
|
import type { IInterval } from '@openpanel/validation';
|
||||||
|
import { Fragment } from 'react';
|
||||||
|
import { useXAxisProps, useYAxisProps } from '../common/axis';
|
||||||
|
import { PreviousDiffIndicatorPure } from '../common/previous-diff-indicator';
|
||||||
|
import { useReportChartContext } from '../context';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: RouterOutputs['chart']['conversion'];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Chart({ data }: Props) {
|
||||||
|
const {
|
||||||
|
report: {
|
||||||
|
previous,
|
||||||
|
interval,
|
||||||
|
projectId,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
range,
|
||||||
|
lineType,
|
||||||
|
events,
|
||||||
|
},
|
||||||
|
isEditMode,
|
||||||
|
options: { hideXAxis, hideYAxis, maxDomain },
|
||||||
|
} = useReportChartContext();
|
||||||
|
const dataLength = data.current.length || 0;
|
||||||
|
const references = api.reference.getChartReferences.useQuery(
|
||||||
|
{
|
||||||
|
projectId,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
range,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
staleTime: 1000 * 60 * 10,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const xAxisProps = useXAxisProps({ interval, hide: hideXAxis });
|
||||||
|
const yAxisProps = useYAxisProps({
|
||||||
|
hide: hideYAxis,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipProvider conversion={data} interval={interval}>
|
||||||
|
<div className={cn('h-full w-full', isEditMode && 'card p-4')}>
|
||||||
|
<ResponsiveContainer>
|
||||||
|
<LineChart>
|
||||||
|
<CartesianGrid
|
||||||
|
strokeDasharray="3 3"
|
||||||
|
horizontal={true}
|
||||||
|
vertical={false}
|
||||||
|
className="stroke-border"
|
||||||
|
/>
|
||||||
|
{references.data?.map((ref) => (
|
||||||
|
<ReferenceLine
|
||||||
|
key={ref.id}
|
||||||
|
x={ref.date.getTime()}
|
||||||
|
stroke={'#94a3b8'}
|
||||||
|
strokeDasharray={'3 3'}
|
||||||
|
label={{
|
||||||
|
value: ref.title,
|
||||||
|
position: 'centerTop',
|
||||||
|
fill: '#334155',
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
|
fontSize={10}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<YAxis {...yAxisProps} domain={[0, 100]} />
|
||||||
|
<XAxis {...xAxisProps} allowDuplicatedCategory={false} />
|
||||||
|
<Tooltip />
|
||||||
|
{data.current.map((serie, index) => {
|
||||||
|
const color = getChartColor(index);
|
||||||
|
return (
|
||||||
|
<Fragment key={serie.id}>
|
||||||
|
<Line
|
||||||
|
data={serie.data}
|
||||||
|
dot={false}
|
||||||
|
name={`rate_${index}`}
|
||||||
|
dataKey="rate"
|
||||||
|
stroke={color}
|
||||||
|
type={lineType}
|
||||||
|
isAnimationActive={false}
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
data={serie.data}
|
||||||
|
dot={false}
|
||||||
|
name={`prev_rate_${index}`}
|
||||||
|
dataKey="previousRate"
|
||||||
|
stroke={color}
|
||||||
|
type={lineType}
|
||||||
|
isAnimationActive={false}
|
||||||
|
strokeWidth={1}
|
||||||
|
strokeOpacity={0.5}
|
||||||
|
/>
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { Tooltip, TooltipProvider } = createChartTooltip<
|
||||||
|
NonNullable<
|
||||||
|
RouterOutputs['chart']['conversion']['current'][number]
|
||||||
|
>['data'][number],
|
||||||
|
{
|
||||||
|
conversion: RouterOutputs['chart']['conversion'];
|
||||||
|
interval: IInterval;
|
||||||
|
}
|
||||||
|
>(({ data, context }) => {
|
||||||
|
const { date } = data[0]!;
|
||||||
|
const formatDate = useFormatDateInterval(context.interval);
|
||||||
|
const number = useNumber();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex justify-between gap-8 text-muted-foreground">
|
||||||
|
<div>{formatDate(date)}</div>
|
||||||
|
</div>
|
||||||
|
{context.conversion.current.map((serie, index) => {
|
||||||
|
const item = data[index];
|
||||||
|
if (!item) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const prevItem =
|
||||||
|
context.conversion?.previous?.[item.serieIndex]?.data[item.index];
|
||||||
|
|
||||||
|
const title =
|
||||||
|
serie.breakdowns.length > 0
|
||||||
|
? (serie.breakdowns.join(',') ?? 'Not set')
|
||||||
|
: 'Conversion';
|
||||||
|
return (
|
||||||
|
<div className="row gap-2" key={serie.id}>
|
||||||
|
<div
|
||||||
|
className="w-[3px] rounded-full"
|
||||||
|
style={{ background: getChartColor(index) }}
|
||||||
|
/>
|
||||||
|
<div className="col flex-1 gap-1">
|
||||||
|
<div className="flex items-center gap-1">{title}</div>
|
||||||
|
<div className="flex justify-between gap-8 font-mono font-medium">
|
||||||
|
<div className="col gap-1">
|
||||||
|
<span>{number.formatWithUnit(item.rate / 100, '%')}</span>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
({number.format(item.total)})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PreviousDiffIndicatorPure
|
||||||
|
{...getPreviousMetric(item.rate, prevItem?.rate)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { api } from '@/trpc/client';
|
||||||
|
|
||||||
|
import { cn } from '@/utils/cn';
|
||||||
|
import { AspectContainer } from '../aspect-container';
|
||||||
|
import { ReportChartEmpty } from '../common/empty';
|
||||||
|
import { ReportChartError } from '../common/error';
|
||||||
|
import { ReportChartLoading } from '../common/loading';
|
||||||
|
import { useReportChartContext } from '../context';
|
||||||
|
import { Chart } from './chart';
|
||||||
|
import { Summary } from './summary';
|
||||||
|
|
||||||
|
export function ReportConversionChart() {
|
||||||
|
const { isLazyLoading, report } = useReportChartContext();
|
||||||
|
|
||||||
|
const res = api.chart.conversion.useQuery(report, {
|
||||||
|
keepPreviousData: true,
|
||||||
|
staleTime: 1000 * 60 * 1,
|
||||||
|
enabled: !isLazyLoading,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
isLazyLoading ||
|
||||||
|
res.isLoading ||
|
||||||
|
(res.isFetching && !res.data?.current.length)
|
||||||
|
) {
|
||||||
|
return <Loading />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.isError) {
|
||||||
|
return <Error />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.data.current.length === 0) {
|
||||||
|
return <Empty />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Summary data={res.data} />
|
||||||
|
<AspectContainer>
|
||||||
|
<Chart data={res.data} />
|
||||||
|
</AspectContainer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Loading() {
|
||||||
|
return (
|
||||||
|
<AspectContainer>
|
||||||
|
<ReportChartLoading />
|
||||||
|
</AspectContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Error() {
|
||||||
|
return (
|
||||||
|
<AspectContainer>
|
||||||
|
<ReportChartError />
|
||||||
|
</AspectContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Empty() {
|
||||||
|
return (
|
||||||
|
<AspectContainer>
|
||||||
|
<ReportChartEmpty />
|
||||||
|
</AspectContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
'use client';
|
||||||
|
import type { RouterOutputs } from '@/trpc/client';
|
||||||
|
import React, { useMemo } from 'react';
|
||||||
|
|
||||||
|
import { Stats, StatsCard } from '@/components/stats';
|
||||||
|
import { useNumber } from '@/hooks/useNumerFormatter';
|
||||||
|
import { formatDate } from '@/utils/date';
|
||||||
|
import { average, getPreviousMetric, sum } from '@openpanel/common';
|
||||||
|
import { ChevronRightIcon } from 'lucide-react';
|
||||||
|
import { PreviousDiffIndicatorPure } from '../common/previous-diff-indicator';
|
||||||
|
import { useReportChartContext } from '../context';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: RouterOutputs['chart']['conversion'];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Summary({ data }: Props) {
|
||||||
|
const number = useNumber();
|
||||||
|
const { report } = useReportChartContext();
|
||||||
|
|
||||||
|
const bestConversionRateMatch = useMemo(() => {
|
||||||
|
return data.current.reduce(
|
||||||
|
(acc, serie, serieIndex) => {
|
||||||
|
const serieMax = serie.data.reduce(
|
||||||
|
(maxInSerie, item, dataIndex) => {
|
||||||
|
if (item.rate > maxInSerie.rate) {
|
||||||
|
return { rate: item.rate, serieIndex, dataIndex };
|
||||||
|
}
|
||||||
|
return maxInSerie;
|
||||||
|
},
|
||||||
|
{ rate: 0, serieIndex, dataIndex: 0 },
|
||||||
|
);
|
||||||
|
|
||||||
|
return serieMax.rate > acc.rate ? serieMax : acc;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rate: 0,
|
||||||
|
serieIndex: 0,
|
||||||
|
dataIndex: 0,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}, [data.current]);
|
||||||
|
|
||||||
|
const worstConversionRateMatch = useMemo(() => {
|
||||||
|
return data.current.reduce(
|
||||||
|
(acc, serie, serieIndex) => {
|
||||||
|
const serieMin = serie.data.reduce(
|
||||||
|
(minInSerie, item, dataIndex) => {
|
||||||
|
if (item.rate < minInSerie.rate) {
|
||||||
|
return { rate: item.rate, serieIndex, dataIndex };
|
||||||
|
}
|
||||||
|
return minInSerie;
|
||||||
|
},
|
||||||
|
{ rate: 100, serieIndex, dataIndex: 0 },
|
||||||
|
);
|
||||||
|
|
||||||
|
return serieMin.rate < acc.rate ? serieMin : acc;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rate: 100,
|
||||||
|
serieIndex: 0,
|
||||||
|
dataIndex: 0,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}, [data.current]);
|
||||||
|
const bestConversionRate =
|
||||||
|
data.current[bestConversionRateMatch.serieIndex]?.data[
|
||||||
|
bestConversionRateMatch.dataIndex
|
||||||
|
];
|
||||||
|
const worstConversionRate =
|
||||||
|
data.current[worstConversionRateMatch.serieIndex]?.data[
|
||||||
|
worstConversionRateMatch.dataIndex
|
||||||
|
];
|
||||||
|
|
||||||
|
const bestAverageConversionRateMatch = data.current.reduce(
|
||||||
|
(acc, serie) => {
|
||||||
|
const averageRate = average(serie.data.map((item) => item.rate));
|
||||||
|
return averageRate > acc.averageRate ? { serie, averageRate } : acc;
|
||||||
|
},
|
||||||
|
{ serie: data.current[0], averageRate: 0 },
|
||||||
|
);
|
||||||
|
const worstAverageConversionRateMatch = data.current.reduce(
|
||||||
|
(acc, serie) => {
|
||||||
|
const averageRate = average(serie.data.map((item) => item.rate));
|
||||||
|
return averageRate < acc.averageRate ? { serie, averageRate } : acc;
|
||||||
|
},
|
||||||
|
{ serie: data.current[0], averageRate: 100 },
|
||||||
|
);
|
||||||
|
|
||||||
|
const averageConversionRate = average(
|
||||||
|
data.current.map((serie) => {
|
||||||
|
return average(serie.data.map((item) => item.rate));
|
||||||
|
}, 0),
|
||||||
|
);
|
||||||
|
|
||||||
|
const averageConversionRatePrevious =
|
||||||
|
average(
|
||||||
|
data.previous?.map((serie) => {
|
||||||
|
return average(serie.data.map((item) => item.rate));
|
||||||
|
}) ?? [],
|
||||||
|
) ?? 0;
|
||||||
|
|
||||||
|
const sumConversions = data.current.reduce((acc, serie) => {
|
||||||
|
return acc + sum(serie.data.map((item) => item.conversions));
|
||||||
|
}, 0);
|
||||||
|
const sumConversionsPrevious = data.previous?.reduce((acc, serie) => {
|
||||||
|
return acc + sum(serie.data.map((item) => item.conversions));
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
const hasManySeries = data.current.length > 1;
|
||||||
|
|
||||||
|
const getConversionRateNode = (
|
||||||
|
item: RouterOutputs['chart']['conversion']['current'][0]['data'][0],
|
||||||
|
) => {
|
||||||
|
const breakdowns = item.serie.breakdowns.join(', ');
|
||||||
|
if (breakdowns) {
|
||||||
|
return (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
On{' '}
|
||||||
|
<span className="text-foreground">
|
||||||
|
{item.serie.breakdowns.join(', ')}
|
||||||
|
</span>{' '}
|
||||||
|
with{' '}
|
||||||
|
<span className="text-foreground">
|
||||||
|
{number.formatWithUnit(item.rate / 100, '%')}
|
||||||
|
</span>{' '}
|
||||||
|
at {formatDate(new Date(item.date))}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
<span className="text-foreground">
|
||||||
|
{number.formatWithUnit(item.rate / 100, '%')}
|
||||||
|
</span>{' '}
|
||||||
|
at {formatDate(new Date(item.date))}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stats className="my-4 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<StatsCard
|
||||||
|
title="Flow"
|
||||||
|
value={
|
||||||
|
<div className="row flex-wrap gap-1">
|
||||||
|
{report.events.map((event, index) => {
|
||||||
|
return (
|
||||||
|
<div key={event.id} className="row items-center gap-2">
|
||||||
|
{index !== 0 && <ChevronRightIcon className="size-3" />}
|
||||||
|
<span>{event.name}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{bestAverageConversionRateMatch && hasManySeries && (
|
||||||
|
<StatsCard
|
||||||
|
title="Best breakdown (avg)"
|
||||||
|
value={
|
||||||
|
<span>
|
||||||
|
{bestAverageConversionRateMatch.serie?.breakdowns.join(', ')}{' '}
|
||||||
|
<span className="text-muted-foreground">with</span>{' '}
|
||||||
|
{number.formatWithUnit(
|
||||||
|
bestAverageConversionRateMatch.averageRate / 100,
|
||||||
|
'%',
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{worstAverageConversionRateMatch && hasManySeries && (
|
||||||
|
<StatsCard
|
||||||
|
title="Worst breakdown (avg)"
|
||||||
|
value={
|
||||||
|
<span>
|
||||||
|
{worstAverageConversionRateMatch.serie?.breakdowns.join(', ')}{' '}
|
||||||
|
<span className="text-muted-foreground">with</span>{' '}
|
||||||
|
{number.formatWithUnit(
|
||||||
|
worstAverageConversionRateMatch.averageRate / 100,
|
||||||
|
'%',
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<StatsCard
|
||||||
|
title="Average conversion rate"
|
||||||
|
value={number.formatWithUnit(averageConversionRate / 100, '%')}
|
||||||
|
enhancer={
|
||||||
|
data.previous && (
|
||||||
|
<PreviousDiffIndicatorPure
|
||||||
|
{...getPreviousMetric(
|
||||||
|
averageConversionRate,
|
||||||
|
averageConversionRatePrevious,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<StatsCard
|
||||||
|
title="Total conversions"
|
||||||
|
value={number.format(sumConversions)}
|
||||||
|
enhancer={
|
||||||
|
data.previous && (
|
||||||
|
<PreviousDiffIndicatorPure
|
||||||
|
{...getPreviousMetric(sumConversions, sumConversionsPrevious)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{bestConversionRate && (
|
||||||
|
<StatsCard
|
||||||
|
title="Best conversion rate"
|
||||||
|
value={getConversionRateNode(bestConversionRate)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{worstConversionRate && (
|
||||||
|
<StatsCard
|
||||||
|
title="Worst conversion rate"
|
||||||
|
value={getConversionRateNode(worstConversionRate)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Stats>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -31,7 +31,7 @@ type Props = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const Metric = ({
|
export const Metric = ({
|
||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
enhancer,
|
enhancer,
|
||||||
@@ -218,7 +218,7 @@ export function Tables({
|
|||||||
{
|
{
|
||||||
name: 'Completed',
|
name: 'Completed',
|
||||||
render: (item) => number.format(item.count),
|
render: (item) => number.format(item.count),
|
||||||
className: 'text-right font-mono',
|
className: 'text-right font-mono hidden @xl:block',
|
||||||
width: '82px',
|
width: '82px',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -227,7 +227,7 @@ export function Tables({
|
|||||||
item.dropoffCount !== null && item.dropoffPercent !== null
|
item.dropoffCount !== null && item.dropoffPercent !== null
|
||||||
? number.format(item.dropoffCount)
|
? number.format(item.dropoffCount)
|
||||||
: null,
|
: null,
|
||||||
className: 'text-right font-mono',
|
className: 'text-right font-mono hidden @xl:block',
|
||||||
width: '110px',
|
width: '110px',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -341,7 +341,8 @@ export function Chart({ data }: { data: RouterOutputs['chart']['funnel'] }) {
|
|||||||
const { Tooltip, TooltipProvider } = createChartTooltip<
|
const { Tooltip, TooltipProvider } = createChartTooltip<
|
||||||
RechartData,
|
RechartData,
|
||||||
Record<string, unknown>
|
Record<string, unknown>
|
||||||
>(({ data }) => {
|
>(({ data: dataArray }) => {
|
||||||
|
const data = dataArray[0]!;
|
||||||
const number = useNumber();
|
const number = useNumber();
|
||||||
const variants = Object.keys(data).filter((key) =>
|
const variants = Object.keys(data).filter((key) =>
|
||||||
key.startsWith('step:data:'),
|
key.startsWith('step:data:'),
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { ReportAreaChart } from './area';
|
|||||||
import { ReportBarChart } from './bar';
|
import { ReportBarChart } from './bar';
|
||||||
import type { ReportChartProps } from './context';
|
import type { ReportChartProps } from './context';
|
||||||
import { ReportChartProvider } from './context';
|
import { ReportChartProvider } from './context';
|
||||||
|
import { ReportConversionChart } from './conversion';
|
||||||
import { ReportFunnelChart } from './funnel';
|
import { ReportFunnelChart } from './funnel';
|
||||||
import { ReportHistogramChart } from './histogram';
|
import { ReportHistogramChart } from './histogram';
|
||||||
import { ReportLineChart } from './line';
|
import { ReportLineChart } from './line';
|
||||||
@@ -51,6 +52,8 @@ export function ReportChart(props: ReportChartProps) {
|
|||||||
return <ReportFunnelChart />;
|
return <ReportFunnelChart />;
|
||||||
case 'retention':
|
case 'retention':
|
||||||
return <ReportRetentionChart />;
|
return <ReportRetentionChart />;
|
||||||
|
case 'conversion':
|
||||||
|
return <ReportConversionChart />;
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
LineChartIcon,
|
LineChartIcon,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
PieChartIcon,
|
PieChartIcon,
|
||||||
|
TrendingUpIcon,
|
||||||
UsersIcon,
|
UsersIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
@@ -52,6 +53,7 @@ export function ReportChartType({ className }: ReportChartTypeProps) {
|
|||||||
metric: GaugeIcon,
|
metric: GaugeIcon,
|
||||||
retention: UsersIcon,
|
retention: UsersIcon,
|
||||||
map: Globe2Icon,
|
map: Globe2Icon,
|
||||||
|
conversion: TrendingUpIcon,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -76,10 +78,11 @@ export function ReportChartType({ className }: ReportChartTypeProps) {
|
|||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
key={item.value}
|
key={item.value}
|
||||||
onClick={() => dispatch(changeChartType(item.value))}
|
onClick={() => dispatch(changeChartType(item.value))}
|
||||||
|
className="group"
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
<DropdownMenuShortcut>
|
<DropdownMenuShortcut>
|
||||||
<Icon className="size-4" />
|
<Icon className="size-4 group-hover:text-blue-500 group-hover:scale-125 transition-all group-hover:rotate-12" />
|
||||||
</DropdownMenuShortcut>
|
</DropdownMenuShortcut>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ export function ReportInterval({ className }: ReportIntervalProps) {
|
|||||||
chartType !== 'histogram' &&
|
chartType !== 'histogram' &&
|
||||||
chartType !== 'area' &&
|
chartType !== 'area' &&
|
||||||
chartType !== 'metric' &&
|
chartType !== 'metric' &&
|
||||||
chartType !== 'retention'
|
chartType !== 'retention' &&
|
||||||
|
chartType !== 'conversion'
|
||||||
) {
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,9 +15,12 @@ export function ReportLineType({ className }: ReportLineTypeProps) {
|
|||||||
const chartType = useSelector((state) => state.report.chartType);
|
const chartType = useSelector((state) => state.report.chartType);
|
||||||
const type = useSelector((state) => state.report.lineType);
|
const type = useSelector((state) => state.report.lineType);
|
||||||
|
|
||||||
if (chartType !== 'linear' && chartType !== 'area') {
|
if (
|
||||||
|
chartType !== 'conversion' &&
|
||||||
|
chartType !== 'linear' &&
|
||||||
|
chartType !== 'area'
|
||||||
|
)
|
||||||
return null;
|
return null;
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Combobox
|
<Combobox
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ export function ReportEvents() {
|
|||||||
const showAddFilter = !['retention'].includes(chartType);
|
const showAddFilter = !['retention'].includes(chartType);
|
||||||
const showDisplayNameInput = !['retention'].includes(chartType);
|
const showDisplayNameInput = !['retention'].includes(chartType);
|
||||||
const isAddEventDisabled =
|
const isAddEventDisabled =
|
||||||
chartType === 'retention' && selectedEvents.length >= 2;
|
(chartType === 'retention' || chartType === 'conversion') &&
|
||||||
|
selectedEvents.length >= 2;
|
||||||
const dispatchChangeEvent = useDebounceFn((event: IChartEvent) => {
|
const dispatchChangeEvent = useDebounceFn((event: IChartEvent) => {
|
||||||
dispatch(changeEvent(event));
|
dispatch(changeEvent(event));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,7 +9,10 @@ import { ReportSettings } from './ReportSettings';
|
|||||||
|
|
||||||
export function ReportSidebar() {
|
export function ReportSidebar() {
|
||||||
const { chartType } = useSelector((state) => state.report);
|
const { chartType } = useSelector((state) => state.report);
|
||||||
const showFormula = chartType !== 'funnel' && chartType !== 'retention';
|
const showFormula =
|
||||||
|
chartType !== 'conversion' &&
|
||||||
|
chartType !== 'funnel' &&
|
||||||
|
chartType !== 'retention';
|
||||||
const showBreakdown = chartType !== 'retention';
|
const showBreakdown = chartType !== 'retention';
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -10,21 +10,25 @@ export function Stats({
|
|||||||
return (
|
return (
|
||||||
<div className="@container">
|
<div className="@container">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn('overflow-hidden rounded border bg-card', className)}
|
||||||
'grid overflow-hidden rounded border bg-background @xl:grid-cols-3 @4xl:grid-cols-6',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StatsCard({ title, value }: { title: string; value: string }) {
|
export function StatsCard({
|
||||||
|
title,
|
||||||
|
value,
|
||||||
|
enhancer,
|
||||||
|
}: { title: string; value: React.ReactNode; enhancer?: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="col gap-2 p-4 ring-[0.5px] ring-border">
|
<div className="col gap-2 p-4 ring-[0.5px] ring-border">
|
||||||
<div className="text-muted-foreground">{title}</div>
|
<div className="text-muted-foreground text-sm">{title}</div>
|
||||||
<div className="truncate font-mono text-2xl font-bold">{value}</div>
|
<div className="row justify-between gap-4">
|
||||||
|
<div className="font-mono text-lg font-bold leading-snug">{value}</div>
|
||||||
|
<div>{enhancer}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ export function WidgetTable<T>({
|
|||||||
<div
|
<div
|
||||||
key={keyExtractor(item)}
|
key={keyExtractor(item)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'group/row relative border-b border-border last:border-0 h-8',
|
'group/row relative border-b border-border last:border-0 h-8 overflow-hidden',
|
||||||
columnClassName,
|
columnClassName,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -24,12 +24,21 @@ export async function deleteProjects() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await ch.command({
|
if (process.env.SELF_HOSTED) {
|
||||||
query: `DELETE FROM ${TABLE_NAMES.events} WHERE project_id IN (${projects.map((project) => escape(project.id)).join(',')});`,
|
await ch.command({
|
||||||
clickhouse_settings: {
|
query: `DELETE FROM ${TABLE_NAMES.events} WHERE project_id IN (${projects.map((project) => escape(project.id)).join(',')});`,
|
||||||
lightweight_deletes_sync: 0,
|
clickhouse_settings: {
|
||||||
},
|
lightweight_deletes_sync: 0,
|
||||||
});
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await ch.command({
|
||||||
|
query: `DELETE FROM ${TABLE_NAMES.events}_replicated ON CLUSTER '{cluster}' WHERE project_id IN (${projects.map((project) => escape(project.id)).join(',')});`,
|
||||||
|
clickhouse_settings: {
|
||||||
|
lightweight_deletes_sync: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
logger.info(`Deleted ${projects.length} projects`, {
|
logger.info(`Deleted ${projects.length} projects`, {
|
||||||
projects,
|
projects,
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ export const chartTypes = {
|
|||||||
map: 'Map',
|
map: 'Map',
|
||||||
funnel: 'Funnel',
|
funnel: 'Funnel',
|
||||||
retention: 'Retention',
|
retention: 'Retention',
|
||||||
|
conversion: 'Conversion',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const lineTypes = {
|
export const lineTypes = {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export * from './src/services/salt.service';
|
|||||||
export * from './src/services/share.service';
|
export * from './src/services/share.service';
|
||||||
export * from './src/services/session.service';
|
export * from './src/services/session.service';
|
||||||
export * from './src/services/funnel.service';
|
export * from './src/services/funnel.service';
|
||||||
|
export * from './src/services/conversion.service';
|
||||||
export * from './src/services/user.service';
|
export * from './src/services/user.service';
|
||||||
export * from './src/services/reference.service';
|
export * from './src/services/reference.service';
|
||||||
export * from './src/services/id.service';
|
export * from './src/services/id.service';
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- DropForeignKey
|
||||||
|
ALTER TABLE "clients" DROP CONSTRAINT "clients_organizationId_fkey";
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "clients" ADD CONSTRAINT "clients_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "ChartType" ADD VALUE 'conversion';
|
||||||
@@ -230,7 +230,7 @@ model Client {
|
|||||||
type ClientType @default(write)
|
type ClientType @default(write)
|
||||||
projectId String?
|
projectId String?
|
||||||
project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||||
organization Organization @relation(fields: [organizationId], references: [id])
|
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||||
organizationId String
|
organizationId String
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@@ -257,6 +257,7 @@ enum ChartType {
|
|||||||
map
|
map
|
||||||
funnel
|
funnel
|
||||||
retention
|
retention
|
||||||
|
conversion
|
||||||
}
|
}
|
||||||
|
|
||||||
model Dashboard {
|
model Dashboard {
|
||||||
|
|||||||
199
packages/db/src/services/conversion.service.ts
Normal file
199
packages/db/src/services/conversion.service.ts
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
import { NOT_SET_VALUE } from '@openpanel/constants';
|
||||||
|
import type { IChartInput } from '@openpanel/validation';
|
||||||
|
import { omit } from 'ramda';
|
||||||
|
import { TABLE_NAMES, ch } from '../clickhouse/client';
|
||||||
|
import { clix } from '../clickhouse/query-builder';
|
||||||
|
import {
|
||||||
|
getEventFiltersWhereClause,
|
||||||
|
getSelectPropertyKey,
|
||||||
|
} from './chart.service';
|
||||||
|
|
||||||
|
export class ConversionService {
|
||||||
|
constructor(private client: typeof ch) {}
|
||||||
|
|
||||||
|
async getConversion({
|
||||||
|
projectId,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
funnelGroup,
|
||||||
|
funnelWindow = 24,
|
||||||
|
events,
|
||||||
|
breakdowns = [],
|
||||||
|
interval,
|
||||||
|
}: Omit<IChartInput, 'range' | 'previous' | 'metric' | 'chartType'>) {
|
||||||
|
const group = funnelGroup === 'profile_id' ? 'profile_id' : 'session_id';
|
||||||
|
const breakdownColumns = breakdowns.map(
|
||||||
|
(b, index) => `${getSelectPropertyKey(b.name)} as b_${index}`,
|
||||||
|
);
|
||||||
|
const breakdownGroupBy = breakdowns.map((b, index) => `b_${index}`);
|
||||||
|
|
||||||
|
if (events.length !== 2) {
|
||||||
|
throw new Error('events must be an array of two events');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!startDate || !endDate) {
|
||||||
|
throw new Error('startDate and endDate are required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventA = events[0]!;
|
||||||
|
const eventB = events[1]!;
|
||||||
|
const whereA = Object.values(
|
||||||
|
getEventFiltersWhereClause(eventA.filters),
|
||||||
|
).join(' AND ');
|
||||||
|
const whereB = Object.values(
|
||||||
|
getEventFiltersWhereClause(eventB.filters),
|
||||||
|
).join(' AND ');
|
||||||
|
|
||||||
|
const eventACte = clix(this.client)
|
||||||
|
.select([
|
||||||
|
`DISTINCT ${group}`,
|
||||||
|
'created_at AS a_time',
|
||||||
|
`${clix.toStartOf('created_at', interval)} AS event_day`,
|
||||||
|
...breakdownColumns,
|
||||||
|
])
|
||||||
|
.from(TABLE_NAMES.events)
|
||||||
|
.where('project_id', '=', projectId)
|
||||||
|
.where('name', '=', eventA.name)
|
||||||
|
.rawWhere(whereA)
|
||||||
|
.where('created_at', 'BETWEEN', [
|
||||||
|
clix.datetime(startDate),
|
||||||
|
clix.datetime(endDate),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const eventBCte = clix(this.client)
|
||||||
|
.select([group, 'created_at AS b_time'])
|
||||||
|
.from(TABLE_NAMES.events)
|
||||||
|
.where('project_id', '=', projectId)
|
||||||
|
.where('name', '=', eventB.name)
|
||||||
|
.rawWhere(whereB)
|
||||||
|
.where('created_at', 'BETWEEN', [
|
||||||
|
clix.datetime(startDate),
|
||||||
|
clix.datetime(endDate),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const query = clix(this.client)
|
||||||
|
.with('event_a', eventACte)
|
||||||
|
.with('event_b', eventBCte)
|
||||||
|
.select<{
|
||||||
|
event_day: string;
|
||||||
|
total_first: number;
|
||||||
|
conversions: number;
|
||||||
|
conversion_rate_percentage: number;
|
||||||
|
[key: string]: string | number; // For breakdown columns
|
||||||
|
}>([
|
||||||
|
'event_day',
|
||||||
|
...breakdownGroupBy,
|
||||||
|
'count(*) AS total_first',
|
||||||
|
'sum(if(conversion_time IS NOT NULL, 1, 0)) AS conversions',
|
||||||
|
'round(100.0 * sum(if(conversion_time IS NOT NULL, 1, 0)) / count(*), 2) AS conversion_rate_percentage',
|
||||||
|
])
|
||||||
|
.from(
|
||||||
|
clix.exp(`
|
||||||
|
(SELECT
|
||||||
|
a.${group},
|
||||||
|
a.a_time,
|
||||||
|
a.event_day,
|
||||||
|
${breakdownGroupBy.length ? `${breakdownGroupBy.join(', ')},` : ''}
|
||||||
|
nullIf(min(b.b_time), '1970-01-01 00:00:00.000') AS conversion_time
|
||||||
|
FROM event_a AS a
|
||||||
|
LEFT JOIN event_b AS b ON a.${group} = b.${group}
|
||||||
|
AND b.b_time BETWEEN a.a_time AND a.a_time + INTERVAL ${funnelWindow} HOUR
|
||||||
|
GROUP BY a.${group}, a.a_time, a.event_day${breakdownGroupBy.length ? `, ${breakdownGroupBy.join(', ')}` : ''})
|
||||||
|
`),
|
||||||
|
)
|
||||||
|
.groupBy(['event_day', ...breakdownGroupBy]);
|
||||||
|
|
||||||
|
for (const order of ['event_day', ...breakdownGroupBy]) {
|
||||||
|
query.orderBy(order);
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await query.execute();
|
||||||
|
return this.toSeries(results, breakdowns).map((serie, serieIndex) => {
|
||||||
|
return {
|
||||||
|
...serie,
|
||||||
|
data: serie.data.map((d, index) => ({
|
||||||
|
...d,
|
||||||
|
timestamp: new Date(d.date).getTime(),
|
||||||
|
serieIndex,
|
||||||
|
index,
|
||||||
|
serie: omit(['data'], serie),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private toSeries(
|
||||||
|
data: {
|
||||||
|
event_day: string;
|
||||||
|
total_first: number;
|
||||||
|
conversions: number;
|
||||||
|
conversion_rate_percentage: number;
|
||||||
|
[key: string]: string | number;
|
||||||
|
}[],
|
||||||
|
breakdowns: { name: string }[] = [],
|
||||||
|
) {
|
||||||
|
if (!breakdowns.length) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'conversion',
|
||||||
|
breakdowns: [],
|
||||||
|
data: data.map((d) => ({
|
||||||
|
date: d.event_day,
|
||||||
|
total: d.total_first,
|
||||||
|
conversions: d.conversions,
|
||||||
|
rate: d.conversion_rate_percentage,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group by breakdown values
|
||||||
|
const series = data.reduce(
|
||||||
|
(acc, d) => {
|
||||||
|
const key =
|
||||||
|
breakdowns.map((b, index) => d[`b_${index}`]).join('|') ||
|
||||||
|
NOT_SET_VALUE;
|
||||||
|
if (!acc[key]) {
|
||||||
|
acc[key] = {
|
||||||
|
id: key,
|
||||||
|
breakdowns: breakdowns.map(
|
||||||
|
(b, index) => (d[`b_${index}`] || NOT_SET_VALUE) as string,
|
||||||
|
),
|
||||||
|
data: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
acc[key]!.data.push({
|
||||||
|
date: d.event_day,
|
||||||
|
total: d.total_first,
|
||||||
|
conversions: d.conversions,
|
||||||
|
rate: d.conversion_rate_percentage,
|
||||||
|
});
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
id: string;
|
||||||
|
breakdowns: string[];
|
||||||
|
data: {
|
||||||
|
date: string;
|
||||||
|
total: number;
|
||||||
|
conversions: number;
|
||||||
|
rate: number;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
>,
|
||||||
|
);
|
||||||
|
|
||||||
|
return Object.values(series).map((serie, serieIndex) => ({
|
||||||
|
...serie,
|
||||||
|
data: serie.data.map((item, dataIndex) => ({
|
||||||
|
...item,
|
||||||
|
dataIndex,
|
||||||
|
serieIndex,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const conversionService = new ConversionService(ch);
|
||||||
38
packages/db/test.ts
Normal file
38
packages/db/test.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { conversionService } from './src/services/conversion.service';
|
||||||
|
// 68/37
|
||||||
|
async function main() {
|
||||||
|
const conversion = await conversionService.getConversion({
|
||||||
|
projectId: 'kiddokitchen-app',
|
||||||
|
startDate: '2025-02-01',
|
||||||
|
endDate: '2025-03-01',
|
||||||
|
funnelGroup: 'session_id',
|
||||||
|
breakdowns: [
|
||||||
|
{
|
||||||
|
name: 'os',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
interval: 'day',
|
||||||
|
events: [
|
||||||
|
{
|
||||||
|
segment: 'event',
|
||||||
|
name: 'screen_view',
|
||||||
|
filters: [
|
||||||
|
{
|
||||||
|
name: 'path',
|
||||||
|
operator: 'is',
|
||||||
|
value: ['Start'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
segment: 'event',
|
||||||
|
name: 'sign_up',
|
||||||
|
filters: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
console.dir(conversion, { depth: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
@@ -5,6 +5,7 @@ import { z } from 'zod';
|
|||||||
import {
|
import {
|
||||||
TABLE_NAMES,
|
TABLE_NAMES,
|
||||||
chQuery,
|
chQuery,
|
||||||
|
conversionService,
|
||||||
createSqlBuilder,
|
createSqlBuilder,
|
||||||
db,
|
db,
|
||||||
funnelService,
|
funnelService,
|
||||||
@@ -32,7 +33,6 @@ import {
|
|||||||
getChart,
|
getChart,
|
||||||
getChartPrevStartEndDate,
|
getChartPrevStartEndDate,
|
||||||
getChartStartEndDate,
|
getChartStartEndDate,
|
||||||
getFunnelData,
|
|
||||||
} from './chart.helpers';
|
} from './chart.helpers';
|
||||||
|
|
||||||
function utc(date: string | Date) {
|
function utc(date: string | Date) {
|
||||||
@@ -197,6 +197,29 @@ export const chartRouter = createTRPCRouter({
|
|||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
conversion: protectedProcedure.input(zChartInput).query(async ({ input }) => {
|
||||||
|
const currentPeriod = getChartStartEndDate(input);
|
||||||
|
const previousPeriod = getChartPrevStartEndDate(currentPeriod);
|
||||||
|
|
||||||
|
const [current, previous] = await Promise.all([
|
||||||
|
conversionService.getConversion({ ...input, ...currentPeriod }),
|
||||||
|
input.previous
|
||||||
|
? conversionService.getConversion({ ...input, ...previousPeriod })
|
||||||
|
: Promise.resolve(null),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
current: current.map((serie, sIndex) => ({
|
||||||
|
...serie,
|
||||||
|
data: serie.data.map((d, dIndex) => ({
|
||||||
|
...d,
|
||||||
|
previousRate: previous?.[sIndex]?.data?.[dIndex]?.rate,
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
previous,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
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