feature(dashboard): add new retention chart type
This commit is contained in:
committed by
Carl-Gerhard Lindesvärd
parent
e2065da16e
commit
f977c5454a
113
apps/dashboard/src/components/report-chart/retention/chart.tsx
Normal file
113
apps/dashboard/src/components/report-chart/retention/chart.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
'use client';
|
||||
|
||||
import type { RouterOutputs } from '@/trpc/client';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { getChartColor } from '@/utils/theme';
|
||||
import {
|
||||
Area,
|
||||
CartesianGrid,
|
||||
ComposedChart,
|
||||
ReferenceLine,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
|
||||
import { average, round } from '@openpanel/common';
|
||||
import { fix } from 'mathjs';
|
||||
import { useXAxisProps, useYAxisProps } from '../common/axis';
|
||||
import { useReportChartContext } from '../context';
|
||||
import { RetentionTooltip } from './tooltip';
|
||||
|
||||
interface Props {
|
||||
data: RouterOutputs['chart']['cohort'];
|
||||
}
|
||||
|
||||
export function Chart({ data }: Props) {
|
||||
const {
|
||||
report: { interval },
|
||||
isEditMode,
|
||||
options: { hideXAxis, hideYAxis },
|
||||
} = useReportChartContext();
|
||||
|
||||
const xAxisProps = useXAxisProps({ interval, hide: hideXAxis });
|
||||
const yAxisProps = useYAxisProps({
|
||||
data: [100],
|
||||
hide: hideYAxis,
|
||||
tickFormatter: (value) => `${value}%`,
|
||||
});
|
||||
const averageRow = data[0];
|
||||
const averageRetentionRate = average(averageRow?.percentages || [], true);
|
||||
const rechartData = averageRow?.percentages.map((item, index, list) => ({
|
||||
days: index,
|
||||
percentage: item,
|
||||
value: averageRow.values[index],
|
||||
sum: averageRow.sum,
|
||||
}));
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cn('h-full w-full', isEditMode && 'card p-4')}>
|
||||
<ResponsiveContainer>
|
||||
<ComposedChart data={rechartData}>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
horizontal={true}
|
||||
vertical={true}
|
||||
className="stroke-border"
|
||||
/>
|
||||
<YAxis {...yAxisProps} dataKey="retentionRate" domain={[0, 100]} />
|
||||
<XAxis
|
||||
{...xAxisProps}
|
||||
dataKey="days"
|
||||
allowDuplicatedCategory
|
||||
scale="linear"
|
||||
tickFormatter={(value) => value.toString()}
|
||||
tickCount={31}
|
||||
interval={0}
|
||||
/>
|
||||
<Tooltip content={<RetentionTooltip />} />
|
||||
<defs>
|
||||
<linearGradient id={'color'} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop
|
||||
offset="0%"
|
||||
stopColor={getChartColor(0)}
|
||||
stopOpacity={0.8}
|
||||
/>
|
||||
<stop
|
||||
offset="100%"
|
||||
stopColor={getChartColor(0)}
|
||||
stopOpacity={0.1}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<ReferenceLine
|
||||
y={averageRetentionRate}
|
||||
stroke={getChartColor(1)}
|
||||
strokeWidth={2}
|
||||
strokeDasharray="3 3"
|
||||
strokeOpacity={0.5}
|
||||
strokeLinecap="round"
|
||||
label={{
|
||||
value: `Average (${round(averageRetentionRate, 2)} %)`,
|
||||
fill: getChartColor(1),
|
||||
position: 'insideBottomRight',
|
||||
fontSize: 12,
|
||||
}}
|
||||
/>
|
||||
<Area
|
||||
dataKey="percentage"
|
||||
fill={'url(#color)'}
|
||||
type={'monotone'}
|
||||
isAnimationActive={false}
|
||||
strokeWidth={2}
|
||||
stroke={getChartColor(0)}
|
||||
fillOpacity={0.1}
|
||||
/>
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
103
apps/dashboard/src/components/report-chart/retention/index.tsx
Normal file
103
apps/dashboard/src/components/report-chart/retention/index.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
'use client';
|
||||
|
||||
import { api } from '@/trpc/client';
|
||||
|
||||
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 CohortTable from './table';
|
||||
|
||||
export function ReportRetentionChart() {
|
||||
const {
|
||||
report: {
|
||||
events,
|
||||
range,
|
||||
projectId,
|
||||
startDate,
|
||||
endDate,
|
||||
criteria,
|
||||
interval,
|
||||
},
|
||||
isLazyLoading,
|
||||
} = useReportChartContext();
|
||||
const firstEvent = (events[0]?.filters[0]?.value ?? []).map(String);
|
||||
const secondEvent = (events[1]?.filters[0]?.value ?? []).map(String);
|
||||
const isEnabled = firstEvent.length > 0 && secondEvent.length > 0;
|
||||
const res = api.chart.cohort.useQuery(
|
||||
{
|
||||
firstEvent,
|
||||
secondEvent,
|
||||
projectId,
|
||||
range,
|
||||
startDate,
|
||||
endDate,
|
||||
criteria,
|
||||
interval,
|
||||
},
|
||||
{
|
||||
enabled: isEnabled,
|
||||
},
|
||||
);
|
||||
|
||||
if (!isEnabled) {
|
||||
return <Disabled />;
|
||||
}
|
||||
|
||||
if (isLazyLoading || res.isLoading) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
if (res.isError) {
|
||||
return <Error />;
|
||||
}
|
||||
|
||||
if (res.data.length === 0) {
|
||||
return <Empty />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="col gap-4">
|
||||
<AspectContainer>
|
||||
<Chart data={res.data} />
|
||||
</AspectContainer>
|
||||
<CohortTable data={res.data} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Loading() {
|
||||
return (
|
||||
<AspectContainer>
|
||||
<ReportChartLoading />
|
||||
</AspectContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function Error() {
|
||||
return (
|
||||
<AspectContainer>
|
||||
<ReportChartError />
|
||||
</AspectContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function Empty() {
|
||||
return (
|
||||
<AspectContainer>
|
||||
<ReportChartEmpty />
|
||||
</AspectContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function Disabled() {
|
||||
return (
|
||||
<AspectContainer>
|
||||
<ReportChartEmpty title="Select 2 events">
|
||||
We need two events to determine the retention rate.
|
||||
</ReportChartEmpty>
|
||||
</AspectContainer>
|
||||
);
|
||||
}
|
||||
136
apps/dashboard/src/components/report-chart/retention/table.tsx
Normal file
136
apps/dashboard/src/components/report-chart/retention/table.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import { ProjectLink } from '@/components/links';
|
||||
import { Tooltiper } from '@/components/ui/tooltip';
|
||||
import { useNumber } from '@/hooks/useNumerFormatter';
|
||||
import type { RouterOutputs } from '@/trpc/client';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { max, min, sum } from '@openpanel/common';
|
||||
import { intervals } from '@openpanel/constants';
|
||||
import type React from 'react';
|
||||
import { useReportChartContext } from '../context';
|
||||
|
||||
type CohortData = RouterOutputs['chart']['cohort'];
|
||||
|
||||
type CohortTableProps = {
|
||||
data: CohortData;
|
||||
};
|
||||
|
||||
const CohortTable: React.FC<CohortTableProps> = ({ data }) => {
|
||||
const {
|
||||
report: { unit, interval },
|
||||
} = useReportChartContext();
|
||||
const isPercentage = unit === '%';
|
||||
const number = useNumber();
|
||||
const highestValue = max(data.map((row) => max(row.values)));
|
||||
const lowestValue = min(data.map((row) => min(row.values)));
|
||||
const rowWithHigestSum = data.find(
|
||||
(row) => row.sum === max(data.map((row) => row.sum)),
|
||||
);
|
||||
|
||||
const getBackground = (value: number | undefined) => {
|
||||
if (!value)
|
||||
return {
|
||||
backgroundClassName: '',
|
||||
opacity: 0,
|
||||
};
|
||||
|
||||
const percentage = isPercentage
|
||||
? value / 100
|
||||
: (value - lowestValue) / (highestValue - lowestValue);
|
||||
const opacity = Math.max(0.05, percentage);
|
||||
|
||||
return {
|
||||
backgroundClassName: 'bg-highlight dark:bg-emerald-700',
|
||||
opacity,
|
||||
};
|
||||
};
|
||||
|
||||
const thClassName =
|
||||
'h-10 align-top pt-3 whitespace-nowrap font-semibold text-muted-foreground';
|
||||
|
||||
return (
|
||||
<div className="relative card overflow-hidden">
|
||||
<div
|
||||
className={'h-10 absolute left-0 right-0 top-px bg-def-100 border-b'}
|
||||
/>
|
||||
<div className="w-full overflow-x-auto hide-scrollbar">
|
||||
<div className="min-w-full relative">
|
||||
<table className="w-full table-auto whitespace-nowrap">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={cn(thClassName, 'sticky left-0 z-10')}>
|
||||
<div className="bg-def-100">
|
||||
<div className="h-10 center-center -mt-3">Date</div>
|
||||
</div>
|
||||
</th>
|
||||
<th className={cn(thClassName, 'pr-1')}>Total profiles</th>
|
||||
{data[0]?.values.map((column, index) => (
|
||||
<th
|
||||
key={index.toString()}
|
||||
className={cn(thClassName, 'capitalize')}
|
||||
>
|
||||
{index === 0 ? `< ${interval} 1` : `${interval} ${index}`}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((row) => {
|
||||
const values = isPercentage ? row.percentages : row.values;
|
||||
return (
|
||||
<tr key={row.cohort_interval}>
|
||||
<td className="sticky left-0 bg-card z-10 w-36 p-0">
|
||||
<div className="h-10 center-center font-medium text-muted-foreground px-4">
|
||||
{row.cohort_interval}
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-0 min-w-12">
|
||||
<div className={cn('font-mono rounded px-3 font-medium')}>
|
||||
{number.format(row?.sum)}
|
||||
{row.cohort_interval ===
|
||||
rowWithHigestSum?.cohort_interval && ' 🚀'}
|
||||
</div>
|
||||
</td>
|
||||
{values.map((value, index) => {
|
||||
const { opacity, backgroundClassName } =
|
||||
getBackground(value);
|
||||
return (
|
||||
<td
|
||||
key={row.cohort_interval + index.toString()}
|
||||
className="p-0 min-w-24"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'h-10 center-center font-mono hover:shadow-[inset_0_0_0_2px_rgb(255,255,255)] relative',
|
||||
opacity > 0.7 &&
|
||||
'text-white [text-shadow:_0_0_3px_rgb(0_0_0_/_20%)]',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
backgroundClassName,
|
||||
'w-full h-full inset-0 absolute',
|
||||
)}
|
||||
style={{
|
||||
opacity,
|
||||
}}
|
||||
/>
|
||||
<div className="relative">
|
||||
{number.formatWithUnit(value, unit)}
|
||||
{value === highestValue && ' 🚀'}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CohortTable;
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useNumber } from '@/hooks/useNumerFormatter';
|
||||
import type { RouterOutputs } from '@/trpc/client';
|
||||
import { useReportChartContext } from '../context';
|
||||
|
||||
type Props = {
|
||||
active?: boolean;
|
||||
payload?: Array<{
|
||||
payload: any;
|
||||
}>;
|
||||
};
|
||||
export function RetentionTooltip({ active, payload }: Props) {
|
||||
const {
|
||||
report: { interval },
|
||||
} = useReportChartContext();
|
||||
const number = useNumber();
|
||||
if (!active) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!payload?.[0]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { days, percentage, value, sum } = payload[0].payload;
|
||||
|
||||
return (
|
||||
<div className="flex min-w-[200px] flex-col gap-2 rounded-xl border bg-card p-3 shadow-xl">
|
||||
<h3 className="font-semibold capitalize">
|
||||
{interval} {days}
|
||||
</h3>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Retention Rate:</span>
|
||||
<span className="font-medium">
|
||||
{number.formatWithUnit(percentage, '%')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Retained Users:</span>
|
||||
<span className="font-medium">{number.format(value)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Total Users:</span>
|
||||
<span className="font-medium">{number.format(sum)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user