fix(dashboard): improved funnels visually and removed bugs
This commit is contained in:
@@ -7,7 +7,7 @@ export function ColorSquare({ children, className }: ColorSquareProps) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex h-5 w-5 flex-shrink-0 items-center justify-center rounded bg-blue-600 text-sm font-medium text-white [.mini_&]:h-4 [.mini_&]:w-4 [.mini_&]:text-[0.6rem]',
|
'flex h-5 w-5 flex-shrink-0 items-center justify-center rounded bg-blue-600 text-sm font-medium text-white [.mini_&]:h-4 [.mini_&]:w-4 [.mini_&]:text-[0.6rem] font-mono',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -10,11 +10,12 @@ import { getChartColor } from '@/utils/theme';
|
|||||||
import { AlertCircleIcon } from 'lucide-react';
|
import { AlertCircleIcon } from 'lucide-react';
|
||||||
import { last } from 'ramda';
|
import { last } from 'ramda';
|
||||||
|
|
||||||
import { getPreviousMetric } from '@openpanel/common';
|
import { getPreviousMetric, round } from '@openpanel/common';
|
||||||
import { alphabetIds } from '@openpanel/constants';
|
import { alphabetIds } from '@openpanel/constants';
|
||||||
|
|
||||||
import { PreviousDiffIndicator } from '../common/previous-diff-indicator';
|
import { PreviousDiffIndicator } from '../common/previous-diff-indicator';
|
||||||
import { useReportChartContext } from '../context';
|
import { useReportChartContext } from '../context';
|
||||||
|
import { MetricCardNumber } from '../metric/metric-card';
|
||||||
|
|
||||||
const findMostDropoffs = (
|
const findMostDropoffs = (
|
||||||
steps: RouterOutputs['chart']['funnel']['current']['steps'],
|
steps: RouterOutputs['chart']['funnel']['current']['steps'],
|
||||||
@@ -41,65 +42,74 @@ export function Chart({
|
|||||||
const mostDropoffs = findMostDropoffs(steps);
|
const mostDropoffs = findMostDropoffs(steps);
|
||||||
const lastStep = last(steps)!;
|
const lastStep = last(steps)!;
|
||||||
const prevLastStep = last(previous.steps);
|
const prevLastStep = last(previous.steps);
|
||||||
const withWidget = (children: React.ReactNode) => {
|
|
||||||
if (isEditMode) {
|
|
||||||
return (
|
|
||||||
<Widget>
|
|
||||||
<WidgetBody>{children}</WidgetBody>
|
|
||||||
</Widget>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return children;
|
return (
|
||||||
};
|
<div
|
||||||
|
className={cn(
|
||||||
return withWidget(
|
'flex flex-col gap-4 @container',
|
||||||
<div className="flex flex-col gap-4 @container">
|
isEditMode ? 'card' : '-m-4',
|
||||||
|
)}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'border border-border',
|
'border-b border-border bg-def-100',
|
||||||
!isEditMode && 'border-0 border-b',
|
isEditMode && 'rounded-t-md',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-8 p-4">
|
<div className="flex items-center gap-8 p-4 px-8">
|
||||||
|
<div className="flex flex-1 items-center gap-8">
|
||||||
|
<MetricCardNumber
|
||||||
|
label="Converted"
|
||||||
|
value={lastStep.count}
|
||||||
|
enhancer={
|
||||||
|
<PreviousDiffIndicator
|
||||||
|
size="lg"
|
||||||
|
{...getPreviousMetric(lastStep.count, prevLastStep?.count)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<MetricCardNumber
|
||||||
|
label="Percent"
|
||||||
|
value={`${round((lastStep.count / totalSessions) * 100, 1)}%`}
|
||||||
|
enhancer={
|
||||||
|
<PreviousDiffIndicator
|
||||||
|
size="lg"
|
||||||
|
{...getPreviousMetric(lastStep.count, prevLastStep?.count)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<MetricCardNumber
|
||||||
|
label="Most dropoffs"
|
||||||
|
value={mostDropoffs.event.displayName}
|
||||||
|
enhancer={
|
||||||
|
<PreviousDiffIndicator
|
||||||
|
size="lg"
|
||||||
|
{...getPreviousMetric(lastStep.count, prevLastStep?.count)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="hidden shrink-0 gap-2 @xl:flex">
|
<div className="hidden shrink-0 gap-2 @xl:flex">
|
||||||
{steps.map((step) => {
|
{steps.map((step) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex h-20 w-8 items-end overflow-hidden rounded bg-def-200"
|
className="flex h-16 w-4 items-end overflow-hidden rounded bg-def-200"
|
||||||
key={step.event.id}
|
key={step.event.id}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="w-full bg-def-400"
|
className={cn(
|
||||||
|
'bg-def-400 w-full',
|
||||||
|
step.event.id === mostDropoffs.event.id && 'bg-rose-500',
|
||||||
|
)}
|
||||||
style={{ height: `${step.percent}%` }}
|
style={{ height: `${step.percent}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-1 items-center gap-4">
|
|
||||||
<div className="flex flex-1 flex-col">
|
|
||||||
<div className="text-2xl">
|
|
||||||
<span className="font-bold">
|
|
||||||
{lastStep.count} of {totalSessions}
|
|
||||||
</span>{' '}
|
|
||||||
sessions{' '}
|
|
||||||
</div>
|
|
||||||
<div className="text-xl text-muted-foreground">
|
|
||||||
Last period:{' '}
|
|
||||||
<span className="font-semibold">
|
|
||||||
{prevLastStep?.count} of {previous.totalSessions}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<PreviousDiffIndicator
|
|
||||||
size="lg"
|
|
||||||
{...getPreviousMetric(lastStep.count, prevLastStep?.count)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1 divide-y">
|
<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;
|
||||||
@@ -112,8 +122,8 @@ export function Chart({
|
|||||||
<ColorSquare className="absolute left-0 top-0.5">
|
<ColorSquare className="absolute left-0 top-0.5">
|
||||||
{alphabetIds[index]}
|
{alphabetIds[index]}
|
||||||
</ColorSquare>
|
</ColorSquare>
|
||||||
<div className="font-semibold capitalize">
|
<div className="font-semibold mt-1">
|
||||||
{step.event.displayName.replace(/_/g, ' ')}
|
{step.event.displayName}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-8 text-sm">
|
<div className="flex items-center gap-8 text-sm">
|
||||||
<TooltipComplete
|
<TooltipComplete
|
||||||
@@ -122,7 +132,7 @@ export function Chart({
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span>
|
<span>
|
||||||
Last period:{' '}
|
Last period:{' '}
|
||||||
<span className="font-semibold">
|
<span className="font-mono">
|
||||||
{previous.steps[index]?.previousCount}
|
{previous.steps[index]?.previousCount}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -140,7 +150,7 @@ export function Chart({
|
|||||||
Total:
|
Total:
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<span className="text-lg font-bold">
|
<span className="text-lg font-mono">
|
||||||
{step.previousCount}
|
{step.previousCount}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -152,7 +162,7 @@ export function Chart({
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span>
|
<span>
|
||||||
Last period:{' '}
|
Last period:{' '}
|
||||||
<span className="font-semibold">
|
<span className="font-mono">
|
||||||
{previous.steps[index]?.dropoffCount}
|
{previous.steps[index]?.dropoffCount}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -173,7 +183,7 @@ export function Chart({
|
|||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-1 text-lg font-bold',
|
'flex items-center gap-1 text-lg font-mono',
|
||||||
isMostDropoffs && 'text-rose-500',
|
isMostDropoffs && 'text-rose-500',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -189,7 +199,7 @@ export function Chart({
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span>
|
<span>
|
||||||
Last period:{' '}
|
Last period:{' '}
|
||||||
<span className="font-semibold">
|
<span className="font-mono">
|
||||||
{previous.steps[index]?.count}
|
{previous.steps[index]?.count}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -207,7 +217,7 @@ export function Chart({
|
|||||||
Current:
|
Current:
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<span className="text-lg font-bold">{step.count}</span>
|
<span className="text-lg font-mono">{step.count}</span>
|
||||||
{/* <button type="button"
|
{/* <button type="button"
|
||||||
className="ml-2 underline"
|
className="ml-2 underline"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
@@ -226,14 +236,13 @@ export function Chart({
|
|||||||
</div>
|
</div>
|
||||||
<Progress
|
<Progress
|
||||||
size="lg"
|
size="lg"
|
||||||
className="w-full @2xl:w-1/2"
|
className="w-full @2xl:w-1/2 text-white bg-def-200 mt-0.5 dark:text-black"
|
||||||
color={getChartColor(index)}
|
|
||||||
value={percent}
|
value={percent}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>,
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,24 +104,40 @@ export function MetricCard({
|
|||||||
)}
|
)}
|
||||||
</AutoSizer>
|
</AutoSizer>
|
||||||
</div>
|
</div>
|
||||||
<div className="col relative gap-2">
|
<MetricCardNumber
|
||||||
<div className="flex items-center justify-between gap-2">
|
label={<SerieName name={serie.names} />}
|
||||||
<div className="flex min-w-0 items-center gap-2 text-left">
|
value={renderValue(serie.metrics[metric], 'ml-1 font-light text-xl')}
|
||||||
<span className="truncate text-muted-foreground">
|
enhancer={
|
||||||
<SerieName name={serie.names} />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-end justify-between">
|
|
||||||
<div className="truncate font-mono text-3xl font-bold">
|
|
||||||
{renderValue(serie.metrics[metric], 'ml-1 font-light text-xl')}
|
|
||||||
</div>
|
|
||||||
<PreviousDiffIndicator
|
<PreviousDiffIndicator
|
||||||
{...previous}
|
{...previous}
|
||||||
className="text-sm text-muted-foreground"
|
className="text-sm text-muted-foreground"
|
||||||
/>
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MetricCardNumber({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
enhancer,
|
||||||
|
}: {
|
||||||
|
label: React.ReactNode;
|
||||||
|
value: React.ReactNode;
|
||||||
|
enhancer?: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-w-0 flex-col gap-2">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="flex min-w-0 items-center gap-2 text-left">
|
||||||
|
<span className="truncate text-muted-foreground">{label}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-end justify-between">
|
||||||
|
<div className="truncate font-mono text-3xl font-bold">{value}</div>
|
||||||
|
{enhancer}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,16 +6,15 @@ import * as React from 'react';
|
|||||||
const Progress = React.forwardRef<
|
const Progress = React.forwardRef<
|
||||||
React.ElementRef<typeof ProgressPrimitive.Root>,
|
React.ElementRef<typeof ProgressPrimitive.Root>,
|
||||||
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root> & {
|
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root> & {
|
||||||
color: string;
|
|
||||||
size?: 'sm' | 'default' | 'lg';
|
size?: 'sm' | 'default' | 'lg';
|
||||||
}
|
}
|
||||||
>(({ className, value, color, size = 'default', ...props }, ref) => (
|
>(({ className, value, size = 'default', ...props }, ref) => (
|
||||||
<ProgressPrimitive.Root
|
<ProgressPrimitive.Root
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative h-4 w-full min-w-16 overflow-hidden rounded bg-def-200 shadow-sm',
|
'relative h-4 w-full min-w-16 overflow-hidden rounded bg-def-200 shadow-sm',
|
||||||
size === 'sm' && 'h-2',
|
size === 'sm' && 'h-2',
|
||||||
size === 'lg' && 'h-8',
|
size === 'lg' && 'h-5',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -24,11 +23,14 @@ const Progress = React.forwardRef<
|
|||||||
className={'h-full w-full flex-1 rounded bg-primary transition-all'}
|
className={'h-full w-full flex-1 rounded bg-primary transition-all'}
|
||||||
style={{
|
style={{
|
||||||
transform: `translateX(-${100 - (value || 0)}%)`,
|
transform: `translateX(-${100 - (value || 0)}%)`,
|
||||||
background: color,
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{value && size !== 'sm' && (
|
{value && size !== 'sm' && (
|
||||||
<div className="z-5 absolute bottom-0 top-0 flex items-center px-2 text-sm font-semibold">
|
<div
|
||||||
|
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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -240,6 +240,28 @@ 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,
|
||||||
@@ -304,41 +326,9 @@ export async function getFunnelData({
|
|||||||
|
|
||||||
const sql = `SELECT level, count() AS count FROM (${innerSql}) WHERE level != 0 GROUP BY level ORDER BY level DESC`;
|
const sql = `SELECT level, count() AS count FROM (${innerSql}) WHERE level != 0 GROUP BY level ORDER BY level DESC`;
|
||||||
|
|
||||||
const funnelRes = await chQuery<{ level: number; count: number }>(sql);
|
const funnel = await chQuery<{ level: number; count: number }>(sql);
|
||||||
|
const maxLevel = payload.events.length;
|
||||||
if (funnelRes[0]?.level !== payload.events.length) {
|
const filledFunnelRes = fillFunnel(funnel, maxLevel);
|
||||||
funnelRes.unshift({
|
|
||||||
level: payload.events.length,
|
|
||||||
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 totalSessions = last(filledFunnelRes)?.count ?? 0;
|
const totalSessions = last(filledFunnelRes)?.count ?? 0;
|
||||||
const steps = reverse(filledFunnelRes).reduce(
|
const steps = reverse(filledFunnelRes).reduce(
|
||||||
|
|||||||
Reference in New Issue
Block a user