diff --git a/apps/start/src/components/report-chart/conversion/chart.tsx b/apps/start/src/components/report-chart/conversion/chart.tsx
index 5553d243..0506fb92 100644
--- a/apps/start/src/components/report-chart/conversion/chart.tsx
+++ b/apps/start/src/components/report-chart/conversion/chart.tsx
@@ -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 (
+
+ {series.map((serie) => (
+
+
+ 0 ? serie.breakdowns : ['Conversion']
+ }
+ className="font-semibold"
+ />
+
+ ))}
+
+ );
+ }, [series]);
+
return (
-
+
@@ -107,35 +139,48 @@ export function Chart({ data }: Props) {
))}
+ {series.length > 1 && } />}
-
-
+ {series.map((serie) => {
+ const color = getChartColor(serie.index);
+ return (
+
+ );
+ })}
+ {series.map((serie) => {
+ const color = getChartColor(serie.index);
+ return (
+
+ );
+ })}
{typeof averageConversionRate === 'number' &&
averageConversionRate && (
+
);
}
const { Tooltip, TooltipProvider } = createChartTooltip<
- NonNullable<
- RouterOutputs['chart']['conversion']['current'][number]
- >['data'][number],
+ Record,
{
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 (
<>
-
- {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 (
-
-
-
-
{title}
+
+ {index === 0 && (
+
+ {formatDate(date)}
+
+ )}
+
+
+ 0
+ ? serie.breakdowns
+ : ['Conversion']
+ }
+ />
+ 0
+ ? serie.breakdowns
+ : ['Conversion']
+ }
+ />
+
-
- {number.formatWithUnit(item.rate / 100, '%')}
- {item.total}
-
-
- {!!prevItem && (
-
-
+
+ {number.formatWithUnit(rate / 100, '%')}
+ ({total})
+ {prevItem && previousRate !== undefined && (
- ({prevItem?.total})
+ ({number.formatWithUnit(previousRate / 100, '%')})
-
+ )}
+
+ {previousRate !== undefined && (
+
)}
-
-
+
+
);
})}
>
diff --git a/apps/start/src/components/report-chart/conversion/conversion-table.tsx b/apps/start/src/components/report-chart/conversion/conversion-table.tsx
new file mode 100644
index 00000000..02ae54f4
--- /dev/null
+++ b/apps/start/src/components/report-chart/conversion/conversion-table.tsx
@@ -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>;
+}
+
+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 = {};
+ 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 = {
+ 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 = {};
+ 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 (
+
+
{}}
+ search=""
+ onSearchChange={() => {}}
+ onUnselectAll={() => setVisibleSeries([])}
+ />
+
+
+
+
+
+ |
+ Serie
+ |
+
+ Avg Rate
+ |
+
+ Total
+ |
+
+ Conversions
+ |
+ {dates.map((date) => (
+
+ {formatDate(date)}
+ |
+ ))}
+
+
+
+ {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 (
+
+
+
+
+ toggleSerieVisibility(row.serieId)
+ }
+ style={{
+ borderColor: color,
+ backgroundColor: isVisible ? color : 'transparent',
+ }}
+ className="h-4 w-4 shrink-0"
+ />
+
+
+
+
+ |
+
+
+
+ {number.formatWithUnit(row.avgRate / 100, '%')}
+
+ {previousMetric && (
+
+ )}
+
+ |
+
+ {number.format(row.total)}
+ |
+
+ {number.format(row.conversions)}
+ |
+ {dates.map((date) => {
+ const value = row.dateValues[date] ?? 0;
+ return (
+
+ {number.formatWithUnit(value / 100, '%')}
+ |
+ );
+ })}
+
+ );
+ })}
+
+
+
+
+
+ );
+}
diff --git a/apps/start/src/hooks/use-conversion-rechart-data-model.ts b/apps/start/src/hooks/use-conversion-rechart-data-model.ts
new file mode 100644
index 00000000..0f09230c
--- /dev/null
+++ b/apps/start/src/hooks/use-conversion-rechart-data-model.ts
@@ -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 = {
+ 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]);
+}
+
diff --git a/apps/start/src/hooks/use-visible-conversion-series.ts b/apps/start/src/hooks/use-visible-conversion-series.ts
new file mode 100644
index 00000000..f6426604
--- /dev/null
+++ b/apps/start/src/hooks/use-visible-conversion-series.ts
@@ -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(
+ 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]);
+}
+