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