conversion wip

This commit is contained in:
Carl-Gerhard Lindesvärd
2025-11-25 10:18:26 +01:00
parent 958ba535d6
commit 727a218e6b
4 changed files with 538 additions and 68 deletions

View File

@@ -2,9 +2,10 @@ import { pushModal } from '@/modals';
import type { RouterOutputs } from '@/trpc/client'; import type { RouterOutputs } from '@/trpc/client';
import { cn } from '@/utils/cn'; import { cn } from '@/utils/cn';
import { getChartColor } from '@/utils/theme'; import { getChartColor } from '@/utils/theme';
import { useCallback } from 'react'; import React, { useCallback } from 'react';
import { import {
CartesianGrid, CartesianGrid,
Legend,
Line, Line,
LineChart, LineChart,
ReferenceLine, ReferenceLine,
@@ -13,16 +14,25 @@ import {
YAxis, YAxis,
} from 'recharts'; } from 'recharts';
import { createChartTooltip } from '@/components/charts/chart-tooltip'; import {
ChartTooltipHeader,
ChartTooltipItem,
createChartTooltip,
} from '@/components/charts/chart-tooltip';
import { useConversionRechartDataModel } from '@/hooks/use-conversion-rechart-data-model';
import { useFormatDateInterval } from '@/hooks/use-format-date-interval'; import { useFormatDateInterval } from '@/hooks/use-format-date-interval';
import { useNumber } from '@/hooks/use-numer-formatter'; import { useNumber } from '@/hooks/use-numer-formatter';
import { useVisibleConversionSeries } from '@/hooks/use-visible-conversion-series';
import { useTRPC } from '@/integrations/trpc/react'; import { useTRPC } from '@/integrations/trpc/react';
import { average, getPreviousMetric, round } from '@openpanel/common'; import { average, getPreviousMetric, round } from '@openpanel/common';
import type { IInterval } from '@openpanel/validation'; import type { IInterval } from '@openpanel/validation';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useXAxisProps, useYAxisProps } from '../common/axis'; import { useXAxisProps, useYAxisProps } from '../common/axis';
import { PreviousDiffIndicatorPure } from '../common/previous-diff-indicator'; import { PreviousDiffIndicator } from '../common/previous-diff-indicator';
import { SerieIcon } from '../common/serie-icon';
import { SerieName } from '../common/serie-name';
import { useReportChartContext } from '../context'; import { useReportChartContext } from '../context';
import { ConversionTable } from './conversion-table';
interface Props { interface Props {
data: RouterOutputs['chart']['conversion']; data: RouterOutputs['chart']['conversion'];
@@ -34,7 +44,8 @@ export function Chart({ data }: Props) {
isEditMode, isEditMode,
options: { hideXAxis, hideYAxis, maxDomain }, options: { hideXAxis, hideYAxis, maxDomain },
} = useReportChartContext(); } = useReportChartContext();
const dataLength = data.current.length || 0; const { series, setVisibleSeries } = useVisibleConversionSeries(data, 5);
const rechartData = useConversionRechartDataModel(series);
const trpc = useTRPC(); const trpc = useTRPC();
const references = useQuery( const references = useQuery(
trpc.reference.getChartReferences.queryOptions( trpc.reference.getChartReferences.queryOptions(
@@ -56,18 +67,11 @@ export function Chart({ data }: Props) {
}); });
const averageConversionRate = average( const averageConversionRate = average(
data.current.map((serie) => { series.map((serie) => {
return average(serie.data.map((item) => item.rate)); return average(serie.data.map((item) => item.rate));
}, 0), }, 0),
); );
const rechartData = data.current[0].data.map((item) => {
return {
...item,
timestamp: new Date(item.date).getTime(),
};
});
const handleChartClick = useCallback((e: any) => { const handleChartClick = useCallback((e: any) => {
if (e?.activePayload?.[0]) { if (e?.activePayload?.[0]) {
const clickedData = e.activePayload[0].payload; const clickedData = e.activePayload[0].payload;
@@ -79,8 +83,36 @@ export function Chart({ data }: Props) {
} }
}, []); }, []);
const CustomLegend = useCallback(() => {
return (
<div className="flex flex-wrap justify-center gap-x-4 gap-y-1 text-xs mt-4 -mb-2">
{series.map((serie) => (
<div
className="flex items-center gap-1"
key={serie.id}
style={{
color: getChartColor(serie.index),
}}
>
<SerieIcon name={serie.breakdowns} />
<SerieName
name={
serie.breakdowns.length > 0 ? serie.breakdowns : ['Conversion']
}
className="font-semibold"
/>
</div>
))}
</div>
);
}, [series]);
return ( return (
<TooltipProvider conversion={data} interval={interval}> <TooltipProvider
conversion={data}
interval={interval}
visibleSeries={series}
>
<div className={cn('h-full w-full', isEditMode && 'card p-4')}> <div className={cn('h-full w-full', isEditMode && 'card p-4')}>
<ResponsiveContainer> <ResponsiveContainer>
<LineChart data={rechartData} onClick={handleChartClick}> <LineChart data={rechartData} onClick={handleChartClick}>
@@ -107,35 +139,48 @@ export function Chart({ data }: Props) {
))} ))}
<YAxis {...yAxisProps} domain={[0, 100]} /> <YAxis {...yAxisProps} domain={[0, 100]} />
<XAxis {...xAxisProps} allowDuplicatedCategory={false} /> <XAxis {...xAxisProps} allowDuplicatedCategory={false} />
{series.length > 1 && <Legend content={<CustomLegend />} />}
<Tooltip /> <Tooltip />
<Line {series.map((serie) => {
dot={false} const color = getChartColor(serie.index);
dataKey="previousRate" return (
stroke={getChartColor(0)} <Line
type={lineType} key={`${serie.id}:previousRate`}
isAnimationActive={false} dot={false}
strokeWidth={1} dataKey={`${serie.id}:previousRate`}
strokeOpacity={0.5} stroke={color}
/> type={lineType}
<Line isAnimationActive={false}
dataKey="rate" strokeWidth={1}
stroke={getChartColor(0)} strokeOpacity={0.3}
type={lineType} />
isAnimationActive={false} );
strokeWidth={2} })}
/> {series.map((serie) => {
const color = getChartColor(serie.index);
return (
<Line
key={`${serie.id}:rate`}
dataKey={`${serie.id}:rate`}
stroke={color}
type={lineType}
isAnimationActive={false}
strokeWidth={2}
/>
);
})}
{typeof averageConversionRate === 'number' && {typeof averageConversionRate === 'number' &&
averageConversionRate && ( averageConversionRate && (
<ReferenceLine <ReferenceLine
y={averageConversionRate} y={averageConversionRate}
stroke={getChartColor(1)} stroke={getChartColor(series.length)}
strokeWidth={2} strokeWidth={2}
strokeDasharray="3 3" strokeDasharray="3 3"
strokeOpacity={0.5} strokeOpacity={0.5}
strokeLinecap="round" strokeLinecap="round"
label={{ label={{
value: `Average (${round(averageConversionRate, 2)} %)`, value: `Average (${round(averageConversionRate, 2)} %)`,
fill: getChartColor(1), fill: getChartColor(series.length),
position: 'insideBottomRight', position: 'insideBottomRight',
fontSize: 12, fontSize: 12,
}} }}
@@ -144,72 +189,92 @@ export function Chart({ data }: Props) {
</LineChart> </LineChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
<ConversionTable
data={data}
visibleSeries={series}
setVisibleSeries={setVisibleSeries}
/>
</TooltipProvider> </TooltipProvider>
); );
} }
const { Tooltip, TooltipProvider } = createChartTooltip< const { Tooltip, TooltipProvider } = createChartTooltip<
NonNullable< Record<string, any>,
RouterOutputs['chart']['conversion']['current'][number]
>['data'][number],
{ {
conversion: RouterOutputs['chart']['conversion']; conversion: RouterOutputs['chart']['conversion'];
interval: IInterval; interval: IInterval;
visibleSeries: RouterOutputs['chart']['conversion']['current'];
} }
>(({ data, context }) => { >(({ data, context }) => {
if (!data[0]) { if (!data || !data[0]) {
return null; return null;
} }
const { date } = data[0]; const payload = data[0];
const { date } = payload;
const formatDate = useFormatDateInterval({ const formatDate = useFormatDateInterval({
interval: context.interval, interval: context.interval,
short: false, short: false,
}); });
const number = useNumber(); const number = useNumber();
return ( return (
<> <>
<div className="flex justify-between gap-8 text-muted-foreground"> {context.visibleSeries.map((serie, index) => {
<div>{formatDate(date)}</div> const rate = payload[`${serie.id}:rate`];
</div> const total = payload[`${serie.id}:total`];
{context.conversion.current.map((serie, index) => { const previousRate = payload[`${serie.id}:previousRate`];
const item = data[index];
if (!item) { if (rate === undefined) {
return null; return null;
} }
const prevItem = context.conversion?.previous?.[0]?.data[item.index];
const title = const prevSerie = context.conversion?.previous?.find(
serie.breakdowns.length > 0 (p) => p.id === serie.id,
? (serie.breakdowns.join(',') ?? 'Not set') );
: 'Conversion'; const prevItem = prevSerie?.data.find((d) => d.date === date);
const previousMetric = getPreviousMetric(rate, previousRate);
return ( return (
<div className="row gap-2" key={serie.id}> <React.Fragment key={serie.id}>
<div {index === 0 && (
className="w-[3px] rounded-full" <ChartTooltipHeader>
style={{ background: getChartColor(index) }} <div>{formatDate(date)}</div>
/> </ChartTooltipHeader>
<div className="col flex-1 gap-1"> )}
<div className="flex items-center gap-1">{title}</div> <ChartTooltipItem color={getChartColor(index)}>
<div className="flex items-center gap-1">
<SerieIcon
name={
serie.breakdowns.length > 0
? serie.breakdowns
: ['Conversion']
}
/>
<SerieName
name={
serie.breakdowns.length > 0
? serie.breakdowns
: ['Conversion']
}
/>
</div>
<div className="flex justify-between gap-8 font-mono font-medium"> <div className="flex justify-between gap-8 font-mono font-medium">
<div className="col gap-1"> <div className="row gap-1">
<span>{number.formatWithUnit(item.rate / 100, '%')}</span> <span>{number.formatWithUnit(rate / 100, '%')}</span>
<span>{item.total}</span> <span className="text-muted-foreground">({total})</span>
</div> {prevItem && previousRate !== undefined && (
{!!prevItem && (
<div className="col gap-1">
<PreviousDiffIndicatorPure
{...getPreviousMetric(item.rate, prevItem?.rate)}
/>
<span className="text-muted-foreground"> <span className="text-muted-foreground">
({prevItem?.total}) ({number.formatWithUnit(previousRate / 100, '%')})
</span> </span>
</div> )}
</div>
{previousRate !== undefined && (
<PreviousDiffIndicator {...previousMetric} />
)} )}
</div> </div>
</div> </ChartTooltipItem>
</div> </React.Fragment>
); );
})} })}
</> </>

View File

@@ -0,0 +1,326 @@
import { Checkbox } from '@/components/ui/checkbox';
import { useFormatDateInterval } from '@/hooks/use-format-date-interval';
import { useNumber } from '@/hooks/use-numer-formatter';
import { useSelector } from '@/redux';
import type { RouterOutputs } from '@/trpc/client';
import { cn } from '@/utils/cn';
import { getChartColor } from '@/utils/theme';
import { getPreviousMetric } from '@openpanel/common';
import { useMemo } from 'react';
import { PreviousDiffIndicatorPure } from '../common/previous-diff-indicator';
import { ReportTableToolbar } from '../common/report-table-toolbar';
import { SerieIcon } from '../common/serie-icon';
import { SerieName } from '../common/serie-name';
interface ConversionTableProps {
data: RouterOutputs['chart']['conversion'];
visibleSeries: RouterOutputs['chart']['conversion']['current'];
setVisibleSeries: React.Dispatch<React.SetStateAction<string[]>>;
}
export function ConversionTable({
data,
visibleSeries,
setVisibleSeries,
}: ConversionTableProps) {
const number = useNumber();
const interval = useSelector((state) => state.report.interval);
const formatDate = useFormatDateInterval({
interval,
short: true,
});
// Get all unique dates from the first series
const dates = useMemo(
() => data.current[0]?.data.map((item) => item.date) ?? [],
[data.current],
);
// Get all series (including non-visible ones for toggle functionality)
const allSeries = data.current;
// Transform data to table rows with memoization
const rows = useMemo(() => {
return allSeries.map((serie) => {
const dateValues: Record<string, number> = {};
dates.forEach((date) => {
const item = serie.data.find((d) => d.date === date);
dateValues[date] = item?.rate ?? 0;
});
const total = serie.data.reduce((sum, item) => sum + item.total, 0);
const conversions = serie.data.reduce(
(sum, item) => sum + item.conversions,
0,
);
const avgRate =
serie.data.length > 0
? serie.data.reduce((sum, item) => sum + item.rate, 0) /
serie.data.length
: 0;
const prevSerie = data.previous?.find((p) => p.id === serie.id);
const prevAvgRate =
prevSerie && prevSerie.data.length > 0
? prevSerie.data.reduce((sum, item) => sum + item.rate, 0) /
prevSerie.data.length
: undefined;
return {
id: serie.id,
serieId: serie.id,
serieName:
serie.breakdowns.length > 0 ? serie.breakdowns : ['Conversion'],
breakdownValues: serie.breakdowns,
avgRate,
prevAvgRate,
total,
conversions,
dateValues,
};
});
}, [allSeries, dates, data.previous]);
// Calculate ranges for color visualization (memoized)
const { metricRanges, dateRanges } = useMemo(() => {
const metricRanges: Record<string, { min: number; max: number }> = {
avgRate: {
min: Number.POSITIVE_INFINITY,
max: Number.NEGATIVE_INFINITY,
},
total: {
min: Number.POSITIVE_INFINITY,
max: Number.NEGATIVE_INFINITY,
},
conversions: {
min: Number.POSITIVE_INFINITY,
max: Number.NEGATIVE_INFINITY,
},
};
const dateRanges: Record<string, { min: number; max: number }> = {};
dates.forEach((date) => {
dateRanges[date] = {
min: Number.POSITIVE_INFINITY,
max: Number.NEGATIVE_INFINITY,
};
});
rows.forEach((row) => {
// Metric ranges
metricRanges.avgRate.min = Math.min(
metricRanges.avgRate.min,
row.avgRate,
);
metricRanges.avgRate.max = Math.max(
metricRanges.avgRate.max,
row.avgRate,
);
metricRanges.total.min = Math.min(metricRanges.total.min, row.total);
metricRanges.total.max = Math.max(metricRanges.total.max, row.total);
metricRanges.conversions.min = Math.min(
metricRanges.conversions.min,
row.conversions,
);
metricRanges.conversions.max = Math.max(
metricRanges.conversions.max,
row.conversions,
);
// Date ranges
dates.forEach((date) => {
const value = row.dateValues[date] ?? 0;
dateRanges[date]!.min = Math.min(dateRanges[date]!.min, value);
dateRanges[date]!.max = Math.max(dateRanges[date]!.max, value);
});
});
return { metricRanges, dateRanges };
}, [rows, dates]);
// Helper to get background color style
const getCellBackgroundStyle = (
value: number,
min: number,
max: number,
colorClass: 'purple' | 'emerald' = 'emerald',
): React.CSSProperties => {
if (value === 0 || max === min) {
return {};
}
const percentage = (value - min) / (max - min);
const opacity = Math.max(0.05, Math.min(1, percentage));
const backgroundColor =
colorClass === 'purple'
? `rgba(168, 85, 247, ${opacity})`
: `rgba(16, 185, 129, ${opacity})`;
return { backgroundColor };
};
const visibleSeriesIds = useMemo(
() => visibleSeries.map((s) => s.id),
[visibleSeries],
);
const getSerieIndex = (serieId: string): number => {
return allSeries.findIndex((s) => s.id === serieId);
};
const toggleSerieVisibility = (serieId: string) => {
setVisibleSeries((prev) => {
if (prev.includes(serieId)) {
return prev.filter((id) => id !== serieId);
}
return [...prev, serieId];
});
};
if (allSeries.length === 0) {
return null;
}
return (
<div className="flex flex-col border rounded-lg overflow-hidden bg-card mt-8">
<ReportTableToolbar
grouped={false}
onToggleGrouped={() => {}}
search=""
onSearchChange={() => {}}
onUnselectAll={() => setVisibleSeries([])}
/>
<div className="overflow-x-auto">
<div className="inline-block min-w-full align-middle">
<table className="w-full">
<thead className="bg-muted/30 border-b sticky top-0 z-10">
<tr>
<th className="text-left px-4 py-3 text-[10px] uppercase font-semibold sticky left-0 bg-muted/30 z-20 min-w-[200px] border-r">
Serie
</th>
<th className="text-right px-4 py-3 text-[10px] uppercase font-semibold min-w-[100px]">
Avg Rate
</th>
<th className="text-right px-4 py-3 text-[10px] uppercase font-semibold min-w-[100px]">
Total
</th>
<th className="text-right px-4 py-3 text-[10px] uppercase font-semibold min-w-[100px]">
Conversions
</th>
{dates.map((date) => (
<th
key={date}
className="text-right px-4 py-3 text-[10px] uppercase font-semibold min-w-[100px]"
>
{formatDate(date)}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row) => {
const isVisible = visibleSeriesIds.includes(row.serieId);
const serieIndex = getSerieIndex(row.serieId);
const color = getChartColor(serieIndex);
const previousMetric =
row.prevAvgRate !== undefined
? getPreviousMetric(row.avgRate, row.prevAvgRate)
: null;
return (
<tr
key={row.id}
className={cn(
'border-b hover:bg-muted/30 transition-colors',
!isVisible && 'opacity-50',
)}
>
<td className="px-4 py-3 sticky left-0 bg-card z-10 border-r">
<div className="flex items-center gap-2">
<Checkbox
checked={isVisible}
onCheckedChange={() =>
toggleSerieVisibility(row.serieId)
}
style={{
borderColor: color,
backgroundColor: isVisible ? color : 'transparent',
}}
className="h-4 w-4 shrink-0"
/>
<div
className="w-[3px] rounded-full shrink-0"
style={{ background: color }}
/>
<SerieIcon name={row.serieName} />
<SerieName name={row.serieName} className="truncate" />
</div>
</td>
<td
className="px-4 py-3 text-right font-mono text-sm"
style={getCellBackgroundStyle(
row.avgRate,
metricRanges.avgRate.min,
metricRanges.avgRate.max,
'purple',
)}
>
<div className="flex items-center justify-end gap-2">
<span>
{number.formatWithUnit(row.avgRate / 100, '%')}
</span>
{previousMetric && (
<PreviousDiffIndicatorPure {...previousMetric} />
)}
</div>
</td>
<td
className="px-4 py-3 text-right font-mono text-sm"
style={getCellBackgroundStyle(
row.total,
metricRanges.total.min,
metricRanges.total.max,
'purple',
)}
>
{number.format(row.total)}
</td>
<td
className="px-4 py-3 text-right font-mono text-sm"
style={getCellBackgroundStyle(
row.conversions,
metricRanges.conversions.min,
metricRanges.conversions.max,
'purple',
)}
>
{number.format(row.conversions)}
</td>
{dates.map((date) => {
const value = row.dateValues[date] ?? 0;
return (
<td
key={date}
className="px-4 py-3 text-right font-mono text-sm"
style={getCellBackgroundStyle(
value,
dateRanges[date]!.min,
dateRanges[date]!.max,
'emerald',
)}
>
{number.formatWithUnit(value / 100, '%')}
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,44 @@
import type { RouterOutputs } from '@/trpc/client';
import { useMemo } from 'react';
export function useConversionRechartDataModel(
series: RouterOutputs['chart']['conversion']['current'],
) {
return useMemo(() => {
if (!series.length || !series[0]?.data.length) {
return [];
}
// Get all unique dates from the first series (all series should have same dates)
const dates = series[0].data.map((item) => item.date);
return dates.map((date) => {
const baseItem = series[0].data.find((item) => item.date === date);
if (!baseItem) {
return {
date,
timestamp: new Date(date).getTime(),
};
}
// Build data object with all series values
const dataPoint: Record<string, any> = {
date,
timestamp: new Date(date).getTime(),
};
series.forEach((serie) => {
const item = serie.data.find((d) => d.date === date);
if (item) {
dataPoint[`${serie.id}:rate`] = item.rate;
dataPoint[`${serie.id}:previousRate`] = item.previousRate;
dataPoint[`${serie.id}:total`] = item.total;
dataPoint[`${serie.id}:conversions`] = item.conversions;
}
});
return dataPoint;
});
}, [series]);
}

View File

@@ -0,0 +1,35 @@
import type { RouterOutputs } from '@/trpc/client';
import { useEffect, useMemo, useState } from 'react';
export type IVisibleConversionSeries = ReturnType<
typeof useVisibleConversionSeries
>['series'];
export function useVisibleConversionSeries(
data: RouterOutputs['chart']['conversion'],
limit?: number | undefined,
) {
const max = limit ?? 5;
const [visibleSeries, setVisibleSeries] = useState<string[]>(
data?.current?.slice(0, max).map((serie) => serie.id) ?? [],
);
useEffect(() => {
setVisibleSeries(
data?.current?.slice(0, max).map((serie) => serie.id) ?? [],
);
}, [data, max]);
return useMemo(() => {
return {
series: data.current
.map((serie, index) => ({
...serie,
index,
}))
.filter((serie) => visibleSeries.includes(serie.id)),
setVisibleSeries,
} as const;
}, [visibleSeries, data.current]);
}