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 { useNumber } from '@/hooks/useNumerFormatter';
import { cn } from '@/utils/cn'; 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 { Badge } from '../ui/badge';
import { useChartContext } from './chart/ChartProvider'; import { useChartContext } from './chart/ChartProvider';
@@ -29,19 +34,24 @@ interface PreviousDiffIndicatorProps {
state?: string | null | undefined; state?: string | null | undefined;
children?: React.ReactNode; children?: React.ReactNode;
inverted?: boolean; inverted?: boolean;
className?: string;
size?: 'sm' | 'lg';
} }
export function PreviousDiffIndicator({ export function PreviousDiffIndicator({
diff, diff,
state, state,
inverted,
size = 'sm',
children, children,
className,
}: PreviousDiffIndicatorProps) { }: PreviousDiffIndicatorProps) {
const { previous, previousIndicatorInverted } = useChartContext(); const { previous, previousIndicatorInverted } = useChartContext();
const variant = getDiffIndicator( const variant = getDiffIndicator(
previousIndicatorInverted, inverted ?? previousIndicatorInverted,
state, state,
'success', 'bg-emerald-300',
'destructive', 'bg-rose-300',
undefined undefined
); );
const number = useNumber(); const number = useNumber();
@@ -52,62 +62,35 @@ export function PreviousDiffIndicator({
const renderIcon = () => { const renderIcon = () => {
if (state === 'positive') { if (state === 'positive') {
return <TrendingUpIcon size={15} />; return <ArrowUpIcon strokeWidth={3} size={12} color="#000" />;
} }
if (state === 'negative') { if (state === 'negative') {
return <TrendingDownIcon size={15} />; return <ArrowDownIcon strokeWidth={3} size={12} color="#000" />;
} }
return null; return null;
}; };
return ( return (
<> <>
<Badge className="flex gap-1" variant={variant}> <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()} {renderIcon()}
</div>
{number.format(diff)}% {number.format(diff)}%
</Badge> </div>
{children} {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) { export function ChartProvider({ children, ...props }: IChartProviderProps) {
return ( 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 { import {
getDiffIndicator, getDiffIndicator,
PreviousDiffIndicatorText, PreviousDiffIndicator,
} from '../PreviousDiffIndicator'; } from '../PreviousDiffIndicator';
import { useChartContext } from './ChartProvider'; import { useChartContext } from './ChartProvider';
import { SerieName } from './SerieName'; import { SerieName } from './SerieName';
@@ -49,9 +49,9 @@ export function MetricCard({
const graphColors = getDiffIndicator( const graphColors = getDiffIndicator(
previousIndicatorInverted, previousIndicatorInverted,
previous?.state, previous?.state,
'green', '#6ee7b7', // green
'red', '#fda4af', // red
'blue' '#93c5fd' // blue
); );
return ( return (
@@ -64,7 +64,7 @@ export function MetricCard({
> >
<div <div
className={cn( 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' editMode && 'bottom-1'
)} )}
> >
@@ -96,15 +96,14 @@ export function MetricCard({
<SerieName name={serie.names} /> <SerieName name={serie.names} />
</span> </span>
</div> </div>
{/* <PreviousDiffIndicator {...serie.metrics.previous[metric]} /> */}
</div> </div>
<div className="flex items-end justify-between"> <div className="flex items-end justify-between">
<div className="overflow-hidden text-ellipsis whitespace-nowrap text-2xl font-bold"> <div className="overflow-hidden text-ellipsis whitespace-nowrap text-2xl font-bold">
{renderValue(serie.metrics[metric], 'ml-1 font-light text-xl')} {renderValue(serie.metrics[metric], 'ml-1 font-light text-xl')}
</div> </div>
<PreviousDiffIndicatorText <PreviousDiffIndicator
{...previous} {...previous}
className="mb-0.5 text-xs font-medium" className="text-xs text-muted-foreground"
/> />
</div> </div>
</div> </div>

View File

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

View File

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

View File

@@ -9,3 +9,4 @@ export * from './src/slug';
export * from './src/fill-series'; export * from './src/fill-series';
export * from './src/url'; export * from './src/url';
export * from './src/id'; 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, subYears,
} from 'date-fns'; } from 'date-fns';
import * as mathjs from 'mathjs'; 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 { escape } from 'sqlstring';
import { import {
average, average,
completeSerie, completeSerie,
getPreviousMetric,
max, max,
min, min,
round, round,
@@ -26,7 +27,6 @@ import {
sum, sum,
} from '@openpanel/common'; } from '@openpanel/common';
import type { ISerieDataItem } from '@openpanel/common'; import type { ISerieDataItem } from '@openpanel/common';
import { alphabetIds } from '@openpanel/constants';
import { import {
chQuery, chQuery,
createSqlBuilder, createSqlBuilder,
@@ -44,7 +44,6 @@ import type {
IChartRange, IChartRange,
IGetChartDataInput, IGetChartDataInput,
IInterval, IInterval,
PreviousValue,
} from '@openpanel/validation'; } from '@openpanel/validation';
function getEventLegend(event: IChartEvent) { 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 sql = `SELECT level, count() AS count FROM (${innerSql}) GROUP BY level ORDER BY level DESC`;
const [funnelRes, sessionRes] = await Promise.all([ const funnelRes = await chQuery<{ level: number; count: number }>(sql);
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)}')`
),
]);
if (funnelRes[0]?.level !== payload.events.length) { if (funnelRes[0]?.level !== payload.events.length) {
funnelRes.unshift({ funnelRes.unshift({
@@ -318,7 +312,6 @@ export async function getFunnelData({
}); });
} }
const totalSessions = sessionRes[0]?.count ?? 0;
const filledFunnelRes = funnelRes.reduce( const filledFunnelRes = funnelRes.reduce(
(acc, item, index) => { (acc, item, index) => {
const diff = const diff =
@@ -346,6 +339,7 @@ export async function getFunnelData({
[] as typeof funnelRes [] as typeof funnelRes
); );
const totalSessions = last(filledFunnelRes)?.count ?? 0;
const steps = reverse(filledFunnelRes) const steps = reverse(filledFunnelRes)
.filter((item) => item.level !== 0) .filter((item) => item.level !== 0)
.reduce( .reduce(
@@ -663,37 +657,3 @@ export async function getChart(input: IChartInput) {
return final; 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,
};
}