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 ( <> -
-
{formatDate(date)}
-
- {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([])} + /> +
+
+ + + + + + + + {dates.map((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 ( + + + + + + {dates.map((date) => { + const value = row.dateValues[date] ?? 0; + return ( + + ); + })} + + ); + })} + +
+ Serie + + Avg Rate + + Total + + Conversions + + {formatDate(date)} +
+
+ + 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)} + + {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]); +} +