better previous indicator and funnel
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
39
packages/common/src/get-previous-metric.ts
Normal file
39
packages/common/src/get-previous-metric.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user