feature(dashboard): add new retention chart type

This commit is contained in:
Carl-Gerhard Lindesvärd
2024-10-15 20:40:24 +02:00
committed by Carl-Gerhard Lindesvärd
parent e2065da16e
commit f977c5454a
53 changed files with 1463 additions and 364 deletions

View 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>
</>
);
}

View 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>
);
}

View 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;

View File

@@ -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>
);
}