conversion wip
This commit is contained in:
@@ -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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
44
apps/start/src/hooks/use-conversion-rechart-data-model.ts
Normal file
44
apps/start/src/hooks/use-conversion-rechart-data-model.ts
Normal 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]);
|
||||||
|
}
|
||||||
|
|
||||||
35
apps/start/src/hooks/use-visible-conversion-series.ts
Normal file
35
apps/start/src/hooks/use-visible-conversion-series.ts
Normal 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]);
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user