better previous indicator and funnel

This commit is contained in:
Carl-Gerhard Lindesvärd
2024-08-20 23:27:04 +02:00
parent a6b3d341c1
commit 96326ad193
8 changed files with 231 additions and 213 deletions

View File

@@ -1,6 +1,11 @@
import { useNumber } from '@/hooks/useNumerFormatter';
import { cn } from '@/utils/cn';
import { TrendingDownIcon, TrendingUpIcon } from 'lucide-react';
import {
ArrowDownIcon,
ArrowUpIcon,
TrendingDownIcon,
TrendingUpIcon,
} from 'lucide-react';
import { Badge } from '../ui/badge';
import { useChartContext } from './chart/ChartProvider';
@@ -29,19 +34,24 @@ interface PreviousDiffIndicatorProps {
state?: string | null | undefined;
children?: React.ReactNode;
inverted?: boolean;
className?: string;
size?: 'sm' | 'lg';
}
export function PreviousDiffIndicator({
diff,
state,
inverted,
size = 'sm',
children,
className,
}: PreviousDiffIndicatorProps) {
const { previous, previousIndicatorInverted } = useChartContext();
const variant = getDiffIndicator(
previousIndicatorInverted,
inverted ?? previousIndicatorInverted,
state,
'success',
'destructive',
'bg-emerald-300',
'bg-rose-300',
undefined
);
const number = useNumber();
@@ -52,62 +62,35 @@ export function PreviousDiffIndicator({
const renderIcon = () => {
if (state === 'positive') {
return <TrendingUpIcon size={15} />;
return <ArrowUpIcon strokeWidth={3} size={12} color="#000" />;
}
if (state === 'negative') {
return <TrendingDownIcon size={15} />;
return <ArrowDownIcon strokeWidth={3} size={12} color="#000" />;
}
return null;
};
return (
<>
<Badge className="flex gap-1" variant={variant}>
{renderIcon()}
<div
className={cn(
'flex items-center gap-1 font-medium',
size === 'lg' && 'gap-2',
className
)}
>
<div
className={cn(
`flex size-4 items-center justify-center rounded-full`,
variant,
size === 'lg' && 'size-8'
)}
>
{renderIcon()}
</div>
{number.format(diff)}%
</Badge>
</div>
{children}
</>
);
}
export function PreviousDiffIndicatorText({
diff,
state,
className,
}: PreviousDiffIndicatorProps & { className?: string }) {
const { previous, previousIndicatorInverted } = useChartContext();
const number = useNumber();
if (diff === null || diff === undefined || previous === false) {
return null;
}
const renderIcon = () => {
if (state === 'positive') {
return <TrendingUpIcon size={15} />;
}
if (state === 'negative') {
return <TrendingDownIcon size={15} />;
}
return null;
};
return (
<div
className={cn([
'flex items-center gap-0.5',
getDiffIndicator(
previousIndicatorInverted,
state,
'text-emerald-600',
'text-red-600',
undefined
),
className,
])}
>
{renderIcon()}
{number.short(diff)}%
</div>
);
}

View File

@@ -26,7 +26,13 @@ const ChartContext = createContext<IChartContextType | null>(null);
export function ChartProvider({ children, ...props }: IChartProviderProps) {
return (
<ChartContext.Provider value={props}>{children}</ChartContext.Provider>
<ChartContext.Provider
value={
props.chartType === 'funnel' ? { ...props, previous: true } : props
}
>
{children}
</ChartContext.Provider>
);
}

View File

@@ -10,7 +10,7 @@ import type { IChartMetric } from '@openpanel/validation';
import {
getDiffIndicator,
PreviousDiffIndicatorText,
PreviousDiffIndicator,
} from '../PreviousDiffIndicator';
import { useChartContext } from './ChartProvider';
import { SerieName } from './SerieName';
@@ -49,9 +49,9 @@ export function MetricCard({
const graphColors = getDiffIndicator(
previousIndicatorInverted,
previous?.state,
'green',
'red',
'blue'
'#6ee7b7', // green
'#fda4af', // red
'#93c5fd' // blue
);
return (
@@ -64,7 +64,7 @@ export function MetricCard({
>
<div
className={cn(
'pointer-events-none absolute -bottom-1 -left-1 -right-1 top-0 z-0 opacity-20 transition-opacity duration-300 group-hover:opacity-50',
'pointer-events-none absolute -bottom-1 -left-1 -right-1 top-0 z-0 opacity-50 transition-opacity duration-300 group-hover:opacity-100',
editMode && 'bottom-1'
)}
>
@@ -96,15 +96,14 @@ export function MetricCard({
<SerieName name={serie.names} />
</span>
</div>
{/* <PreviousDiffIndicator {...serie.metrics.previous[metric]} /> */}
</div>
<div className="flex items-end justify-between">
<div className="overflow-hidden text-ellipsis whitespace-nowrap text-2xl font-bold">
{renderValue(serie.metrics[metric], 'ml-1 font-light text-xl')}
</div>
<PreviousDiffIndicatorText
<PreviousDiffIndicator
{...previous}
className="mb-0.5 text-xs font-medium"
className="text-xs text-muted-foreground"
/>
</div>
</div>

View File

@@ -5,19 +5,17 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useNumber } from '@/hooks/useNumerFormatter';
import type { IChartData } from '@/trpc/client';
import { cn } from '@/utils/cn';
import { DropdownMenuPortal } from '@radix-ui/react-dropdown-menu';
import { ExternalLinkIcon, FilterIcon } from 'lucide-react';
import { round } from '@openpanel/common';
import { NOT_SET_VALUE } from '@openpanel/constants';
import { PreviousDiffIndicatorText } from '../PreviousDiffIndicator';
import { PreviousDiffIndicator } from '../PreviousDiffIndicator';
import { useChartContext } from './ChartProvider';
import { SerieIcon } from './SerieIcon';
import { SerieName } from './SerieName';
@@ -73,9 +71,8 @@ export function ReportBarChart({ data }: ReportBarChartProps) {
<SerieName name={serie.names} />
</div>
<div className="flex flex-shrink-0 items-center justify-end gap-4">
<PreviousDiffIndicatorText
<PreviousDiffIndicator
{...serie.metrics.previous?.[metric]}
className="text-xs font-medium"
/>
{serie.metrics.previous?.[metric]?.value}
<div className="text-muted-foreground">

View File

@@ -2,20 +2,23 @@
import { ColorSquare } from '@/components/color-square';
import { AutoSizer } from '@/components/react-virtualized-auto-sizer';
import { TooltipComplete } from '@/components/tooltip-complete';
import { Progress } from '@/components/ui/progress';
import { Widget, WidgetBody } from '@/components/widget';
import type { RouterOutputs } from '@/trpc/client';
import { cn } from '@/utils/cn';
import { round } from '@/utils/math';
import { getChartColor } from '@/utils/theme';
import { AlertCircleIcon } from 'lucide-react';
import { AlertCircleIcon, TrendingUp } from 'lucide-react';
import { last } from 'ramda';
import { Cell, Pie, PieChart } from 'recharts';
import { getPreviousMetric } from '@openpanel/common';
import { alphabetIds } from '@openpanel/constants';
import type { IChartInput } from '@openpanel/validation';
import { useChartContext } from '../chart/ChartProvider';
import { PreviousDiffIndicator } from '../PreviousDiffIndicator';
const findMostDropoffs = (
steps: RouterOutputs['chart']['funnel']['current']['steps']
@@ -74,80 +77,43 @@ export function FunnelSteps({
return withWidget(
<div className="flex flex-col gap-4 @container">
<div
className={cn(
'rounded-lg border border-border',
!editMode && 'border-0 p-0'
)}
className={cn('border border-border', !editMode && 'border-0 border-b')}
>
<div className="flex items-center gap-8 p-4">
<div className="hidden shrink-0 @xl:block @xl:w-36">
<AutoSizer disableHeight>
{({ width }) => {
const height = width;
return (
<div className="relative" style={{ width, height }}>
<PieChart width={width} height={height}>
<Pie
data={[
{
value: lastStep.percent,
label: 'Conversion',
},
{
value: 100 - lastStep.percent,
label: 'Dropoff',
},
]}
innerRadius={height / 3}
outerRadius={height / 2 - 10}
isAnimationActive={false}
nameKey="label"
dataKey="value"
>
<Cell strokeWidth={0} className="fill-highlight" />
<Cell strokeWidth={0} className="fill-def-200" />
</Pie>
</PieChart>
<div
className="font-mono absolute inset-0 flex items-center justify-center font-bold"
style={{
fontSize: width / 6,
}}
>
<div>{round(lastStep.percent, 2)}%</div>
</div>
</div>
);
}}
</AutoSizer>
<div className="hidden shrink-0 gap-2 @xl:flex">
{steps.map((step) => {
return (
<div
className="flex h-20 w-8 items-end overflow-hidden rounded bg-def-200"
key={step.event.id}
>
<div
className="w-full bg-def-400"
style={{ height: `${step.percent}%` }}
></div>
</div>
);
})}
</div>
<div>
<div className="mb-1 text-xl font-semibold">Insights</div>
<div className="flex flex-wrap gap-4">
<InsightCard title="Converted">
<span className="font-bold">{lastStep.count}</span>
<span className="mx-2 text-muted-foreground">of</span>
<span className="text-muted-foreground">{totalSessions}</span>
</InsightCard>
<InsightCard
title={hasIncreased ? 'Trending up' : 'Trending down'}
>
<span className="font-bold">{round(lastStep.percent, 2)}%</span>
<span className="mx-2 text-muted-foreground">compared to</span>
<span className="text-muted-foreground">
{round(prevLastStep.percent, 2)}%
</span>
</InsightCard>
<InsightCard title={'Most dropoffs'}>
<div className="flex flex-1 items-center gap-4">
<div className="flex flex-1 flex-col">
<div className="text-2xl">
<span className="font-bold">
{mostDropoffs.event.displayName}
{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>
<span className="mx-2 text-muted-foreground">lost</span>
<span className="text-muted-foreground">
{mostDropoffs.dropoffCount} sessions
</span>
</InsightCard>
</div>
</div>
<PreviousDiffIndicator
size="lg"
{...getPreviousMetric(lastStep.count, prevLastStep.count)}
/>
</div>
</div>
</div>
@@ -167,46 +133,113 @@ export function FunnelSteps({
<div className="font-semibold capitalize">
{step.event.displayName.replace(/_/g, ' ')}
</div>
<div className="flex items-center gap-4 text-sm">
<div className="flex flex-col">
<span className="text-xs text-muted-foreground">
Total:
</span>
<span className="font-semibold">{step.previousCount}</span>
</div>
<div className="flex flex-col">
<span className="text-xs text-muted-foreground">
Dropoff:
</span>
<span
className={cn(
'flex items-center gap-1 font-semibold',
isMostDropoffs && 'text-red-600'
)}
>
{isMostDropoffs && <AlertCircleIcon size={14} />}
{step.dropoffCount}
</span>
</div>
<div className="flex flex-col">
<span className="text-xs text-muted-foreground">
Current:
</span>
<div>
<span className="font-semibold">{step.count}</span>
{/* <button
<div className="flex items-center gap-8 text-sm">
<TooltipComplete
disabled={!previous.steps[index]}
content={
<div className="flex gap-2">
<span>
Last period:{' '}
<span className="font-semibold">
{previous.steps[index]?.previousCount}
</span>
</span>
<PreviousDiffIndicator
{...getPreviousMetric(
step.previousCount,
previous.steps[index]?.previousCount
)}
/>
</div>
}
>
<div className="flex flex-col">
<span className="text-xs text-muted-foreground">
Total:
</span>
<div className="flex items-center gap-4">
<span className="text-lg font-bold">
{step.previousCount}
</span>
</div>
</div>
</TooltipComplete>
<TooltipComplete
disabled={!previous.steps[index]}
content={
<div className="flex gap-2">
<span>
Last period:{' '}
<span className="font-semibold">
{previous.steps[index]?.dropoffCount}
</span>
</span>
<PreviousDiffIndicator
inverted
{...getPreviousMetric(
step.dropoffCount,
previous.steps[index]?.dropoffCount
)}
/>
</div>
}
>
<div className="flex flex-col">
<span className="text-xs text-muted-foreground">
Dropoff:
</span>
<div className="flex items-center gap-4">
<span
className={cn(
'flex items-center gap-1 text-lg font-bold',
isMostDropoffs && 'text-rose-500'
)}
>
{isMostDropoffs && <AlertCircleIcon size={14} />}
{step.dropoffCount}
</span>
</div>
</div>
</TooltipComplete>
<TooltipComplete
disabled={!previous.steps[index]}
content={
<div className="flex gap-2">
<span>
Last period:{' '}
<span className="font-semibold">
{previous.steps[index]?.count}
</span>
</span>
<PreviousDiffIndicator
{...getPreviousMetric(
step.count,
previous.steps[index]?.count
)}
/>
</div>
}
>
<div className="flex flex-col">
<span className="text-xs text-muted-foreground">
Current:
</span>
<div className="flex items-center gap-4">
<span className="text-lg font-bold">{step.count}</span>
{/* <button
className="ml-2 underline"
onClick={() =>
pushModal('FunnelStepDetails', {
...input,
step: index + 1,
pushModal('FunnelStepDetails', {
...input,
step: index + 1,
})
}
>
Inspect
</button> */}
}
>
Inspect
</button> */}
</div>
</div>
</div>
</TooltipComplete>
</div>
</div>
<Progress

View File

@@ -9,3 +9,4 @@ export * from './src/slug';
export * from './src/fill-series';
export * from './src/url';
export * from './src/id';
export * from './src/get-previous-metric';

View File

@@ -0,0 +1,39 @@
import { isNil } from 'ramda';
import type { PreviousValue } from '@openpanel/validation';
import { round } from './math';
export function getPreviousMetric(
current: number,
previous: number | null | undefined
): PreviousValue {
if (isNil(previous)) {
return undefined;
}
const diff = round(
((current > previous
? current / previous
: current < previous
? previous / current
: 0) -
1) *
100,
1
);
return {
diff:
Number.isNaN(diff) || !Number.isFinite(diff) || current === previous
? null
: diff,
state:
current > previous
? 'positive'
: current < previous
? 'negative'
: 'neutral',
value: previous,
};
}

View File

@@ -13,12 +13,13 @@ import {
subYears,
} from 'date-fns';
import * as mathjs from 'mathjs';
import { pluck, repeat, reverse, uniq } from 'ramda';
import { last, pluck, repeat, reverse, uniq } from 'ramda';
import { escape } from 'sqlstring';
import {
average,
completeSerie,
getPreviousMetric,
max,
min,
round,
@@ -26,7 +27,6 @@ import {
sum,
} from '@openpanel/common';
import type { ISerieDataItem } from '@openpanel/common';
import { alphabetIds } from '@openpanel/constants';
import {
chQuery,
createSqlBuilder,
@@ -44,7 +44,6 @@ import type {
IChartRange,
IGetChartDataInput,
IInterval,
PreviousValue,
} from '@openpanel/validation';
function getEventLegend(event: IChartEvent) {
@@ -304,12 +303,7 @@ export async function getFunnelData({
const sql = `SELECT level, count() AS count FROM (${innerSql}) 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 ${TABLE_NAMES.events} WHERE project_id = ${escape(projectId)} AND name = 'session_start' AND (created_at >= '${formatClickhouseDate(startDate)}') AND (created_at <= '${formatClickhouseDate(endDate)}')`
),
]);
const funnelRes = await chQuery<{ level: number; count: number }>(sql);
if (funnelRes[0]?.level !== payload.events.length) {
funnelRes.unshift({
@@ -318,7 +312,6 @@ export async function getFunnelData({
});
}
const totalSessions = sessionRes[0]?.count ?? 0;
const filledFunnelRes = funnelRes.reduce(
(acc, item, index) => {
const diff =
@@ -346,6 +339,7 @@ export async function getFunnelData({
[] as typeof funnelRes
);
const totalSessions = last(filledFunnelRes)?.count ?? 0;
const steps = reverse(filledFunnelRes)
.filter((item) => item.level !== 0)
.reduce(
@@ -663,37 +657,3 @@ export async function getChart(input: IChartInput) {
return final;
}
export function getPreviousMetric(
current: number,
previous: number | null
): PreviousValue {
if (previous === null) {
return undefined;
}
const diff = round(
((current > previous
? current / previous
: current < previous
? previous / current
: 0) -
1) *
100,
1
);
return {
diff:
Number.isNaN(diff) || !Number.isFinite(diff) || current === previous
? null
: diff,
state:
current > previous
? 'positive'
: current < previous
? 'negative'
: 'neutral',
value: previous,
};
}