feat: report editor
commit bfcf271a64c33a60f61f511cec2198d9c8a9c51a Author: Carl-Gerhard Lindesvärd <lindesvard@gmail.com> Date: Wed Nov 26 12:32:40 2025 +0100 wip commit8cd3b89fa3Author: Carl-Gerhard Lindesvärd <lindesvard@gmail.com> Date: Tue Nov 25 22:33:58 2025 +0100 funnel commit95af86dc44Author: Carl-Gerhard Lindesvärd <lindesvard@gmail.com> Date: Tue Nov 25 22:23:25 2025 +0100 wip commit727a218e6bAuthor: Carl-Gerhard Lindesvärd <lindesvard@gmail.com> Date: Tue Nov 25 10:18:26 2025 +0100 conversion wip commit958ba535d6Author: Carl-Gerhard Lindesvärd <lindesvard@gmail.com> Date: Tue Nov 25 10:18:20 2025 +0100 wip commit3bbeb927ccAuthor: Carl-Gerhard Lindesvärd <lindesvard@gmail.com> Date: Tue Nov 25 09:18:48 2025 +0100 wip commitd99335e2f4Author: Carl-Gerhard Lindesvärd <lindesvard@gmail.com> Date: Mon Nov 24 18:08:10 2025 +0100 wip commit1fa61b1ae9Author: Carl-Gerhard Lindesvärd <lindesvard@gmail.com> Date: Mon Nov 24 15:50:28 2025 +0100 ts commit548747d826Author: Carl-Gerhard Lindesvärd <lindesvard@gmail.com> Date: Mon Nov 24 13:17:01 2025 +0100 fix typecheck events -> series commit7b18544085Author: Carl-Gerhard Lindesvärd <lindesvard@gmail.com> Date: Mon Nov 24 13:06:46 2025 +0100 fix report table commit57697a5a39Author: Carl-Gerhard Lindesvärd <lindesvard@gmail.com> Date: Sat Nov 22 00:05:13 2025 +0100 wip commit06fb6c4f3cAuthor: Carl-Gerhard Lindesvärd <lindesvard@gmail.com> Date: Fri Nov 21 11:21:17 2025 +0100 wip commitdd71fd4e11Author: Carl-Gerhard Lindesvärd <lindesvard@gmail.com> Date: Thu Nov 20 13:56:58 2025 +0100 formulas
This commit is contained in:
@@ -153,7 +153,7 @@ export function OverviewMetricCard({
|
||||
width={width}
|
||||
height={height / 4}
|
||||
data={data}
|
||||
style={{ marginTop: (height / 4) * 3 }}
|
||||
style={{ marginTop: (height / 4) * 3, background: 'transparent' }}
|
||||
onMouseMove={(event) => {
|
||||
setCurrentIndex(event.activeTooltipIndex ?? null);
|
||||
}}
|
||||
|
||||
@@ -45,8 +45,9 @@ export default function OverviewTopDevices({
|
||||
projectId,
|
||||
startDate,
|
||||
endDate,
|
||||
events: [
|
||||
series: [
|
||||
{
|
||||
type: 'event',
|
||||
segment: 'user',
|
||||
filters,
|
||||
id: 'A',
|
||||
@@ -81,8 +82,9 @@ export default function OverviewTopDevices({
|
||||
projectId,
|
||||
startDate,
|
||||
endDate,
|
||||
events: [
|
||||
series: [
|
||||
{
|
||||
type: 'event',
|
||||
segment: 'user',
|
||||
filters,
|
||||
id: 'A',
|
||||
@@ -120,8 +122,9 @@ export default function OverviewTopDevices({
|
||||
projectId,
|
||||
startDate,
|
||||
endDate,
|
||||
events: [
|
||||
series: [
|
||||
{
|
||||
type: 'event',
|
||||
segment: 'user',
|
||||
filters,
|
||||
id: 'A',
|
||||
@@ -160,8 +163,9 @@ export default function OverviewTopDevices({
|
||||
projectId,
|
||||
startDate,
|
||||
endDate,
|
||||
events: [
|
||||
series: [
|
||||
{
|
||||
type: 'event',
|
||||
segment: 'user',
|
||||
filters,
|
||||
id: 'A',
|
||||
@@ -199,8 +203,9 @@ export default function OverviewTopDevices({
|
||||
projectId,
|
||||
startDate,
|
||||
endDate,
|
||||
events: [
|
||||
series: [
|
||||
{
|
||||
type: 'event',
|
||||
segment: 'user',
|
||||
filters,
|
||||
id: 'A',
|
||||
@@ -239,8 +244,9 @@ export default function OverviewTopDevices({
|
||||
projectId,
|
||||
startDate,
|
||||
endDate,
|
||||
events: [
|
||||
series: [
|
||||
{
|
||||
type: 'event',
|
||||
segment: 'user',
|
||||
filters,
|
||||
id: 'A',
|
||||
@@ -278,8 +284,9 @@ export default function OverviewTopDevices({
|
||||
projectId,
|
||||
startDate,
|
||||
endDate,
|
||||
events: [
|
||||
series: [
|
||||
{
|
||||
type: 'event',
|
||||
segment: 'user',
|
||||
filters,
|
||||
id: 'A',
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { IChartType } from '@openpanel/validation';
|
||||
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { SerieIcon } from '../report-chart/common/serie-icon';
|
||||
import { Widget, WidgetBody } from '../widget';
|
||||
import { OverviewChartToggle } from './overview-chart-toggle';
|
||||
import { WidgetButtons, WidgetFooter, WidgetHead } from './overview-widget';
|
||||
@@ -37,8 +38,9 @@ export default function OverviewTopEvents({
|
||||
projectId,
|
||||
startDate,
|
||||
endDate,
|
||||
events: [
|
||||
series: [
|
||||
{
|
||||
type: 'event',
|
||||
segment: 'event',
|
||||
filters: [
|
||||
...filters,
|
||||
@@ -78,8 +80,9 @@ export default function OverviewTopEvents({
|
||||
projectId,
|
||||
startDate,
|
||||
endDate,
|
||||
events: [
|
||||
series: [
|
||||
{
|
||||
type: 'event',
|
||||
segment: 'event',
|
||||
filters: [...filters],
|
||||
id: 'A',
|
||||
@@ -112,8 +115,9 @@ export default function OverviewTopEvents({
|
||||
projectId,
|
||||
startDate,
|
||||
endDate,
|
||||
events: [
|
||||
series: [
|
||||
{
|
||||
type: 'event',
|
||||
segment: 'event',
|
||||
filters: [
|
||||
...filters,
|
||||
@@ -168,7 +172,13 @@ export default function OverviewTopEvents({
|
||||
</WidgetHead>
|
||||
<WidgetBody className="p-3">
|
||||
<ReportChart
|
||||
options={{ hideID: true, columns: ['Event', 'Count'] }}
|
||||
options={{
|
||||
hideID: true,
|
||||
columns: ['Event'],
|
||||
renderSerieName(names) {
|
||||
return names[1];
|
||||
},
|
||||
}}
|
||||
report={{
|
||||
...widget.chart.report,
|
||||
previous: false,
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { IChartType } from '@openpanel/validation';
|
||||
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||
import { useTRPC } from '@/integrations/trpc/react';
|
||||
import { pushModal } from '@/modals';
|
||||
import { countries } from '@/translations/countries';
|
||||
import { NOT_SET_VALUE } from '@openpanel/constants';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ChevronRightIcon } from 'lucide-react';
|
||||
@@ -108,13 +109,19 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
|
||||
>
|
||||
{item.prefix && (
|
||||
<span className="mr-1 row inline-flex items-center gap-1">
|
||||
<span>{item.prefix}</span>
|
||||
<span>
|
||||
{countries[
|
||||
item.prefix as keyof typeof countries
|
||||
] ?? item.prefix}
|
||||
</span>
|
||||
<span>
|
||||
<ChevronRightIcon className="size-3" />
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{item.name || 'Not set'}
|
||||
{(countries[item.name as keyof typeof countries] ??
|
||||
item.name) ||
|
||||
'Not set'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
@@ -146,8 +153,9 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
|
||||
projectId,
|
||||
startDate,
|
||||
endDate,
|
||||
events: [
|
||||
series: [
|
||||
{
|
||||
type: 'event',
|
||||
segment: 'event',
|
||||
filters,
|
||||
id: 'A',
|
||||
|
||||
@@ -15,8 +15,9 @@ export const ProfileCharts = memo(
|
||||
const pageViewsChart: IChartProps = {
|
||||
projectId,
|
||||
chartType: 'linear',
|
||||
events: [
|
||||
series: [
|
||||
{
|
||||
type: 'event',
|
||||
segment: 'event',
|
||||
filters: [
|
||||
{
|
||||
@@ -48,8 +49,9 @@ export const ProfileCharts = memo(
|
||||
const eventsChart: IChartProps = {
|
||||
projectId,
|
||||
chartType: 'linear',
|
||||
events: [
|
||||
series: [
|
||||
{
|
||||
type: 'event',
|
||||
segment: 'event',
|
||||
filters: [
|
||||
{
|
||||
|
||||
@@ -7,6 +7,7 @@ import { cn } from '@/utils/cn';
|
||||
import { getChartColor } from '@/utils/theme';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { isSameDay, isSameHour, isSameMonth, isSameWeek } from 'date-fns';
|
||||
import { BookmarkIcon, UsersIcon } from 'lucide-react';
|
||||
import { last } from 'ramda';
|
||||
import { useCallback } from 'react';
|
||||
import {
|
||||
@@ -25,6 +26,10 @@ import {
|
||||
|
||||
import { useDashedStroke } from '@/hooks/use-dashed-stroke';
|
||||
import { useXAxisProps, useYAxisProps } from '../common/axis';
|
||||
import {
|
||||
ChartClickMenu,
|
||||
type ChartClickMenuItem,
|
||||
} from '../common/chart-click-menu';
|
||||
import { ReportChartTooltip } from '../common/report-chart-tooltip';
|
||||
import { ReportTable } from '../common/report-table';
|
||||
import { SerieIcon } from '../common/serie-icon';
|
||||
@@ -45,6 +50,8 @@ export function Chart({ data }: Props) {
|
||||
endDate,
|
||||
range,
|
||||
lineType,
|
||||
series: reportSeries,
|
||||
breakdowns,
|
||||
},
|
||||
isEditMode,
|
||||
options: { hideXAxis, hideYAxis },
|
||||
@@ -126,16 +133,66 @@ export function Chart({ data }: Props) {
|
||||
interval,
|
||||
});
|
||||
|
||||
const handleChartClick = useCallback((e: any) => {
|
||||
if (e?.activePayload?.[0]) {
|
||||
const clickedData = e.activePayload[0].payload;
|
||||
if (clickedData.date) {
|
||||
pushModal('AddReference', {
|
||||
datetime: new Date(clickedData.date).toISOString(),
|
||||
const getMenuItems = useCallback(
|
||||
(e: any, clickedData: any): ChartClickMenuItem[] => {
|
||||
const items: ChartClickMenuItem[] = [];
|
||||
|
||||
if (!clickedData?.date) {
|
||||
return items;
|
||||
}
|
||||
|
||||
// View Users - only show if we have projectId
|
||||
if (projectId) {
|
||||
items.push({
|
||||
label: 'View Users',
|
||||
icon: <UsersIcon size={16} />,
|
||||
onClick: () => {
|
||||
pushModal('ViewChartUsers', {
|
||||
type: 'chart',
|
||||
chartData: data,
|
||||
report: {
|
||||
projectId,
|
||||
series: reportSeries,
|
||||
breakdowns: breakdowns || [],
|
||||
interval,
|
||||
startDate,
|
||||
endDate,
|
||||
range,
|
||||
previous,
|
||||
chartType: 'area',
|
||||
metric: 'sum',
|
||||
},
|
||||
date: clickedData.date,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Add Reference - always show
|
||||
items.push({
|
||||
label: 'Add Reference',
|
||||
icon: <BookmarkIcon size={16} />,
|
||||
onClick: () => {
|
||||
pushModal('AddReference', {
|
||||
datetime: new Date(clickedData.date).toISOString(),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return items;
|
||||
},
|
||||
[
|
||||
projectId,
|
||||
data,
|
||||
reportSeries,
|
||||
breakdowns,
|
||||
interval,
|
||||
startDate,
|
||||
endDate,
|
||||
range,
|
||||
previous,
|
||||
],
|
||||
);
|
||||
|
||||
const { getStrokeDasharray, calcStrokeDasharray, handleAnimationEnd } =
|
||||
useDashedStroke({
|
||||
@@ -144,106 +201,114 @@ export function Chart({ data }: Props) {
|
||||
|
||||
return (
|
||||
<ReportChartTooltip.TooltipProvider references={references.data}>
|
||||
<div className={cn('h-full w-full', isEditMode && 'card p-4')}>
|
||||
<ResponsiveContainer>
|
||||
<ComposedChart data={rechartData} onClick={handleChartClick}>
|
||||
<Customized component={calcStrokeDasharray} />
|
||||
<Line
|
||||
dataKey="calcStrokeDasharray"
|
||||
legendType="none"
|
||||
animationDuration={0}
|
||||
onAnimationEnd={handleAnimationEnd}
|
||||
/>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
horizontal={true}
|
||||
vertical={false}
|
||||
className="stroke-border"
|
||||
/>
|
||||
{references.data?.map((ref) => (
|
||||
<ReferenceLine
|
||||
key={ref.id}
|
||||
x={ref.date.getTime()}
|
||||
stroke={'oklch(from var(--foreground) l c h / 0.1)'}
|
||||
strokeDasharray={'3 3'}
|
||||
label={{
|
||||
value: ref.title,
|
||||
position: 'centerTop',
|
||||
fill: '#334155',
|
||||
fontSize: 12,
|
||||
}}
|
||||
fontSize={10}
|
||||
<ChartClickMenu getMenuItems={getMenuItems}>
|
||||
<div className={cn('h-full w-full', isEditMode && 'card p-4')}>
|
||||
<ResponsiveContainer>
|
||||
<ComposedChart data={rechartData}>
|
||||
<Customized component={calcStrokeDasharray} />
|
||||
<Line
|
||||
dataKey="calcStrokeDasharray"
|
||||
legendType="none"
|
||||
animationDuration={0}
|
||||
onAnimationEnd={handleAnimationEnd}
|
||||
/>
|
||||
))}
|
||||
<YAxis {...yAxisProps} />
|
||||
<XAxis {...xAxisProps} />
|
||||
<Legend content={<CustomLegend />} />
|
||||
<Tooltip content={<ReportChartTooltip.Tooltip />} />
|
||||
{series.map((serie) => {
|
||||
const color = getChartColor(serie.index);
|
||||
return (
|
||||
<defs key={`defs-${serie.id}`}>
|
||||
<linearGradient
|
||||
id={`color${color}`}
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
<stop offset="0%" stopColor={color} stopOpacity={0.8} />
|
||||
<stop offset={'100%'} stopColor={color} stopOpacity={0.1} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
);
|
||||
})}
|
||||
{series.map((serie) => {
|
||||
const color = getChartColor(serie.index);
|
||||
return (
|
||||
<Area
|
||||
key={serie.id}
|
||||
stackId="1"
|
||||
type={lineType}
|
||||
name={serie.id}
|
||||
dataKey={`${serie.id}:count`}
|
||||
strokeDasharray={
|
||||
useDashedLastLine
|
||||
? getStrokeDasharray(`${serie.id}:count`)
|
||||
: undefined
|
||||
}
|
||||
fill={`url(#color${color})`}
|
||||
isAnimationActive={false}
|
||||
fillOpacity={0.7}
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
horizontal={true}
|
||||
vertical={false}
|
||||
className="stroke-border"
|
||||
/>
|
||||
{references.data?.map((ref) => (
|
||||
<ReferenceLine
|
||||
key={ref.id}
|
||||
x={ref.date.getTime()}
|
||||
stroke={'oklch(from var(--foreground) l c h / 0.1)'}
|
||||
strokeDasharray={'3 3'}
|
||||
label={{
|
||||
value: ref.title,
|
||||
position: 'centerTop',
|
||||
fill: '#334155',
|
||||
fontSize: 12,
|
||||
}}
|
||||
fontSize={10}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{previous &&
|
||||
series.map((serie) => {
|
||||
))}
|
||||
<YAxis {...yAxisProps} />
|
||||
<XAxis {...xAxisProps} />
|
||||
<Legend content={<CustomLegend />} />
|
||||
<Tooltip content={<ReportChartTooltip.Tooltip />} />
|
||||
{series.map((serie) => {
|
||||
const color = getChartColor(serie.index);
|
||||
return (
|
||||
<defs key={`defs-${serie.id}`}>
|
||||
<linearGradient
|
||||
id={`color${color}`}
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
<stop offset="0%" stopColor={color} stopOpacity={0.8} />
|
||||
<stop
|
||||
offset={'100%'}
|
||||
stopColor={color}
|
||||
stopOpacity={0.1}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
);
|
||||
})}
|
||||
{series.map((serie) => {
|
||||
const color = getChartColor(serie.index);
|
||||
return (
|
||||
<Area
|
||||
key={`${serie.id}:prev`}
|
||||
stackId="2"
|
||||
key={serie.id}
|
||||
stackId="1"
|
||||
type={lineType}
|
||||
name={`${serie.id}:prev`}
|
||||
dataKey={`${serie.id}:prev:count`}
|
||||
name={serie.id}
|
||||
dataKey={`${serie.id}:count`}
|
||||
strokeDasharray={
|
||||
useDashedLastLine
|
||||
? getStrokeDasharray(`${serie.id}:count`)
|
||||
: undefined
|
||||
}
|
||||
fill={`url(#color${color})`}
|
||||
stroke={color}
|
||||
fill={color}
|
||||
fillOpacity={0.3}
|
||||
strokeOpacity={0.3}
|
||||
strokeWidth={2}
|
||||
isAnimationActive={false}
|
||||
fillOpacity={0.7}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
{isEditMode && (
|
||||
<ReportTable
|
||||
data={data}
|
||||
visibleSeries={series}
|
||||
setVisibleSeries={setVisibleSeries}
|
||||
/>
|
||||
)}
|
||||
{previous &&
|
||||
series.map((serie) => {
|
||||
const color = getChartColor(serie.index);
|
||||
return (
|
||||
<Area
|
||||
key={`${serie.id}:prev`}
|
||||
stackId="2"
|
||||
type={lineType}
|
||||
name={`${serie.id}:prev`}
|
||||
dataKey={`${serie.id}:prev:count`}
|
||||
stroke={color}
|
||||
fill={color}
|
||||
fillOpacity={0.3}
|
||||
strokeOpacity={0.3}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
{isEditMode && (
|
||||
<ReportTable
|
||||
data={data}
|
||||
visibleSeries={series}
|
||||
setVisibleSeries={setVisibleSeries}
|
||||
/>
|
||||
)}
|
||||
</ChartClickMenu>
|
||||
</ReportChartTooltip.TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import React, {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
export interface ChartClickMenuItem {
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface ChartClickMenuProps {
|
||||
children: React.ReactNode;
|
||||
/**
|
||||
* Function that receives the click event and clicked data, returns menu items
|
||||
* This allows conditional menu items based on what was clicked
|
||||
*/
|
||||
getMenuItems: (e: any, clickedData: any) => ChartClickMenuItem[];
|
||||
/**
|
||||
* Optional callback when menu closes
|
||||
*/
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export interface ChartClickMenuHandle {
|
||||
setPosition: (position: { x: number; y: number } | null) => void;
|
||||
getContainerElement: () => HTMLDivElement | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable component for handling chart clicks and showing a dropdown menu
|
||||
* Wraps the chart and handles click position tracking and dropdown positioning
|
||||
*/
|
||||
export const ChartClickMenu = forwardRef<
|
||||
ChartClickMenuHandle,
|
||||
ChartClickMenuProps
|
||||
>(({ children, getMenuItems, onClose }, ref) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [clickPosition, setClickPosition] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
} | null>(null);
|
||||
const [clickedData, setClickedData] = useState<any>(null);
|
||||
|
||||
const [clickEvent, setClickEvent] = useState<any>(null);
|
||||
|
||||
const handleChartClick = useCallback((e: any) => {
|
||||
if (e?.activePayload?.[0] && containerRef.current) {
|
||||
const payload = e.activePayload[0].payload;
|
||||
|
||||
// Calculate click position relative to chart container
|
||||
const containerRect = containerRef.current.getBoundingClientRect();
|
||||
|
||||
// Try to get viewport coordinates from the event
|
||||
// Recharts passes nativeEvent with clientX/clientY (viewport coordinates)
|
||||
let clientX = 0;
|
||||
let clientY = 0;
|
||||
|
||||
if (
|
||||
e.nativeEvent?.clientX !== undefined &&
|
||||
e.nativeEvent?.clientY !== undefined
|
||||
) {
|
||||
// Best case: use nativeEvent client coordinates (viewport coordinates)
|
||||
clientX = e.nativeEvent.clientX;
|
||||
clientY = e.nativeEvent.clientY;
|
||||
} else if (e.clientX !== undefined && e.clientY !== undefined) {
|
||||
// Fallback: use event's clientX/Y directly
|
||||
clientX = e.clientX;
|
||||
clientY = e.clientY;
|
||||
} else if (e.activeCoordinate) {
|
||||
// Last resort: activeCoordinate is SVG-relative, need to find SVG element
|
||||
// and convert to viewport coordinates
|
||||
const svgElement = containerRef.current.querySelector('svg');
|
||||
if (svgElement) {
|
||||
const svgRect = svgElement.getBoundingClientRect();
|
||||
clientX = svgRect.left + (e.activeCoordinate.x ?? 0);
|
||||
clientY = svgRect.top + (e.activeCoordinate.y ?? 0);
|
||||
} else {
|
||||
// If no SVG found, use container position + activeCoordinate
|
||||
clientX = containerRect.left + (e.activeCoordinate.x ?? 0);
|
||||
clientY = containerRect.top + (e.activeCoordinate.y ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
setClickedData(payload);
|
||||
setClickEvent(e); // Store the full event
|
||||
setClickPosition({
|
||||
x: clientX - containerRect.left,
|
||||
y: clientY - containerRect.top,
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const menuItems =
|
||||
clickedData && clickEvent ? getMenuItems(clickEvent, clickedData) : [];
|
||||
|
||||
const handleItemClick = useCallback(
|
||||
(item: ChartClickMenuItem) => {
|
||||
item.onClick();
|
||||
setClickPosition(null);
|
||||
setClickedData(null);
|
||||
setClickEvent(null);
|
||||
if (onClose) {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
if (!open) {
|
||||
setClickPosition(null);
|
||||
setClickedData(null);
|
||||
setClickEvent(null);
|
||||
if (onClose) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
|
||||
// Expose methods via ref (for advanced use cases)
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
setPosition: (position: { x: number; y: number } | null) => {
|
||||
setClickPosition(position);
|
||||
},
|
||||
getContainerElement: () => containerRef.current,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
// Clone children and add onClick handler to chart components
|
||||
const chartWithClickHandler = React.useMemo(() => {
|
||||
const addClickHandler = (node: React.ReactNode): React.ReactNode => {
|
||||
// Handle null, undefined, strings, numbers
|
||||
if (!React.isValidElement(node)) {
|
||||
return node;
|
||||
}
|
||||
|
||||
// Check if this is a chart component
|
||||
const componentName =
|
||||
(node.type as any)?.displayName || (node.type as any)?.name;
|
||||
const isChartComponent =
|
||||
componentName === 'ComposedChart' ||
|
||||
componentName === 'LineChart' ||
|
||||
componentName === 'BarChart' ||
|
||||
componentName === 'AreaChart' ||
|
||||
componentName === 'PieChart' ||
|
||||
componentName === 'ResponsiveContainer';
|
||||
|
||||
// Process children recursively - handle arrays, fragments, and single elements
|
||||
const processChildren = (children: React.ReactNode): React.ReactNode => {
|
||||
if (children == null) {
|
||||
return children;
|
||||
}
|
||||
|
||||
// Handle arrays
|
||||
if (Array.isArray(children)) {
|
||||
return children.map(addClickHandler);
|
||||
}
|
||||
|
||||
// Handle React fragments
|
||||
if (
|
||||
React.isValidElement(children) &&
|
||||
children.type === React.Fragment
|
||||
) {
|
||||
const fragmentElement = children as React.ReactElement<{
|
||||
children?: React.ReactNode;
|
||||
}>;
|
||||
return React.cloneElement(fragmentElement, {
|
||||
children: processChildren(fragmentElement.props.children),
|
||||
});
|
||||
}
|
||||
|
||||
// Recursively process single child
|
||||
return addClickHandler(children);
|
||||
};
|
||||
|
||||
const element = node as React.ReactElement<{
|
||||
children?: React.ReactNode;
|
||||
onClick?: (e: any) => void;
|
||||
}>;
|
||||
|
||||
if (isChartComponent) {
|
||||
// For ResponsiveContainer, we need to add onClick to its child (ComposedChart, etc.)
|
||||
if (componentName === 'ResponsiveContainer') {
|
||||
return React.cloneElement(element, {
|
||||
children: processChildren(element.props.children),
|
||||
});
|
||||
}
|
||||
// For chart components, add onClick directly
|
||||
return React.cloneElement(element, {
|
||||
onClick: handleChartClick,
|
||||
children: processChildren(element.props.children),
|
||||
});
|
||||
}
|
||||
|
||||
// Recursively process children for non-chart components
|
||||
if (element.props.children != null) {
|
||||
return React.cloneElement(element, {
|
||||
children: processChildren(element.props.children),
|
||||
});
|
||||
}
|
||||
|
||||
return node;
|
||||
};
|
||||
|
||||
// Handle multiple children (array) or single child
|
||||
if (Array.isArray(children)) {
|
||||
return children.map(addClickHandler);
|
||||
}
|
||||
return addClickHandler(children);
|
||||
}, [children, handleChartClick]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative h-full w-full">
|
||||
<DropdownMenu
|
||||
open={clickPosition !== null}
|
||||
onOpenChange={handleOpenChange}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: clickPosition?.x ?? -9999,
|
||||
top: clickPosition?.y ?? -9999,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" side="bottom" sideOffset={5}>
|
||||
{menuItems.map((item) => (
|
||||
<DropdownMenuItem
|
||||
key={item.label}
|
||||
onClick={() => handleItemClick(item)}
|
||||
disabled={item.disabled}
|
||||
>
|
||||
{item.icon && <span className="mr-2">{item.icon}</span>}
|
||||
{item.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{chartWithClickHandler}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
ChartClickMenu.displayName = 'ChartClickMenu';
|
||||
@@ -17,10 +17,10 @@ export function ReportChartEmpty({
|
||||
}) {
|
||||
const {
|
||||
isEditMode,
|
||||
report: { events },
|
||||
report: { series },
|
||||
} = useReportChartContext();
|
||||
|
||||
if (events.length === 0) {
|
||||
if (!series || series.length === 0) {
|
||||
return (
|
||||
<div className="card p-4 center-center h-full w-full flex-col relative">
|
||||
<div className="row gap-2 items-end absolute top-4 left-4">
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { List, Rows3, Search, X } from 'lucide-react';
|
||||
|
||||
interface ReportTableToolbarProps {
|
||||
grouped?: boolean;
|
||||
onToggleGrouped?: () => void;
|
||||
search: string;
|
||||
onSearchChange?: (value: string) => void;
|
||||
onUnselectAll?: () => void;
|
||||
}
|
||||
|
||||
export function ReportTableToolbar({
|
||||
grouped,
|
||||
onToggleGrouped,
|
||||
search,
|
||||
onSearchChange,
|
||||
onUnselectAll,
|
||||
}: ReportTableToolbarProps) {
|
||||
return (
|
||||
<div className="col md:row md:items-center gap-2 p-2 border-b md:justify-between">
|
||||
{onSearchChange && (
|
||||
<div className="relative flex-1 w-full md:max-w-sm">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search..."
|
||||
value={search}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
{onToggleGrouped && (
|
||||
<Button
|
||||
variant={'outline'}
|
||||
size="sm"
|
||||
onClick={onToggleGrouped}
|
||||
icon={grouped ? Rows3 : List}
|
||||
>
|
||||
{grouped ? 'Grouped' : 'Flat'}
|
||||
</Button>
|
||||
)}
|
||||
{onUnselectAll && (
|
||||
<Button variant="outline" size="sm" onClick={onUnselectAll} icon={X}>
|
||||
Unselect All
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,742 @@
|
||||
import { getPropertyLabel } from '@/translations/properties';
|
||||
import type { IChartData } from '@/trpc/client';
|
||||
|
||||
export type TableRow = {
|
||||
id: string;
|
||||
serieId: string; // Serie ID for visibility/color lookup
|
||||
serieName: string;
|
||||
breakdownValues: string[];
|
||||
count: number;
|
||||
sum: number;
|
||||
average: number;
|
||||
min: number;
|
||||
max: number;
|
||||
dateValues: Record<string, number>; // date -> count
|
||||
// Group metadata
|
||||
groupKey?: string;
|
||||
parentGroupKey?: string;
|
||||
isSummaryRow?: boolean;
|
||||
};
|
||||
|
||||
export type GroupedTableRow = TableRow & {
|
||||
// For grouped mode, indicates which breakdown levels should show empty cells
|
||||
breakdownDisplay: (string | null)[]; // null means show empty cell
|
||||
};
|
||||
|
||||
/**
|
||||
* Row type that supports TanStack Table's expanding feature
|
||||
* Can represent both group header rows and data rows
|
||||
*/
|
||||
export type ExpandableTableRow = TableRow & {
|
||||
subRows?: ExpandableTableRow[];
|
||||
isGroupHeader?: boolean; // True if this is a group header row
|
||||
groupValue?: string; // The value this group represents
|
||||
groupLevel?: number; // The level in the hierarchy (0-based)
|
||||
breakdownDisplay?: (string | null)[]; // For display purposes
|
||||
};
|
||||
|
||||
/**
|
||||
* Hierarchical group structure for better collapse/expand functionality
|
||||
*/
|
||||
export type GroupedItem<T> = {
|
||||
group: string;
|
||||
items: Array<GroupedItem<T> | T>;
|
||||
level: number;
|
||||
groupKey: string; // Unique key for this group (path-based)
|
||||
parentGroupKey?: string; // Key of parent group
|
||||
};
|
||||
|
||||
/**
|
||||
* Transform flat array of items with hierarchical names into nested group structure
|
||||
* This creates a tree structure that makes it easier to toggle specific groups
|
||||
*/
|
||||
export function groupByNames<T extends { names: string[] }>(
|
||||
items: T[],
|
||||
): Array<GroupedItem<T>> {
|
||||
const rootGroups = new Map<string, GroupedItem<T>>();
|
||||
|
||||
for (const item of items) {
|
||||
const names = item.names;
|
||||
if (names.length === 0) continue;
|
||||
|
||||
// Start with the first level (serie name, level -1)
|
||||
const firstLevel = names[0]!;
|
||||
const rootGroupKey = firstLevel;
|
||||
|
||||
if (!rootGroups.has(firstLevel)) {
|
||||
rootGroups.set(firstLevel, {
|
||||
group: firstLevel,
|
||||
items: [],
|
||||
level: -1, // Serie level
|
||||
groupKey: rootGroupKey,
|
||||
});
|
||||
}
|
||||
|
||||
const rootGroup = rootGroups.get(firstLevel)!;
|
||||
|
||||
// Navigate/create nested groups for remaining levels (breakdowns, level 0+)
|
||||
let currentGroup = rootGroup;
|
||||
let parentGroupKey = rootGroupKey;
|
||||
|
||||
for (let i = 1; i < names.length; i++) {
|
||||
const levelName = names[i]!;
|
||||
const groupKey = `${parentGroupKey}:${levelName}`;
|
||||
const level = i - 1; // Breakdown levels start at 0
|
||||
|
||||
// Find existing group at this level
|
||||
const existingGroup = currentGroup.items.find(
|
||||
(child): child is GroupedItem<T> =>
|
||||
typeof child === 'object' &&
|
||||
'group' in child &&
|
||||
child.group === levelName &&
|
||||
'level' in child &&
|
||||
child.level === level,
|
||||
);
|
||||
|
||||
if (existingGroup) {
|
||||
currentGroup = existingGroup;
|
||||
parentGroupKey = groupKey;
|
||||
} else {
|
||||
// Create new group at this level
|
||||
const newGroup: GroupedItem<T> = {
|
||||
group: levelName,
|
||||
items: [],
|
||||
level,
|
||||
groupKey,
|
||||
parentGroupKey,
|
||||
};
|
||||
currentGroup.items.push(newGroup);
|
||||
currentGroup = newGroup;
|
||||
parentGroupKey = groupKey;
|
||||
}
|
||||
}
|
||||
|
||||
// Add the actual item to the deepest group
|
||||
currentGroup.items.push(item);
|
||||
}
|
||||
|
||||
return Array.from(rootGroups.values());
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten a grouped structure back into a flat array of items
|
||||
* Useful for getting all items in a group or its children
|
||||
*/
|
||||
export function flattenGroupedItems<T>(
|
||||
groupedItems: Array<GroupedItem<T> | T>,
|
||||
): T[] {
|
||||
const result: T[] = [];
|
||||
|
||||
for (const item of groupedItems) {
|
||||
if (item && typeof item === 'object' && 'items' in item) {
|
||||
// It's a group, recursively flatten its items
|
||||
result.push(...flattenGroupedItems(item.items));
|
||||
} else if (item) {
|
||||
// It's an actual item
|
||||
result.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a group by its groupKey in a nested structure
|
||||
*/
|
||||
export function findGroup<T>(
|
||||
groups: Array<GroupedItem<T>>,
|
||||
groupKey: string,
|
||||
): GroupedItem<T> | null {
|
||||
for (const group of groups) {
|
||||
if (group.groupKey === groupKey) {
|
||||
return group;
|
||||
}
|
||||
|
||||
// Search in nested groups
|
||||
for (const item of group.items) {
|
||||
if (item && typeof item === 'object' && 'items' in item) {
|
||||
const found = findGroup([item], groupKey);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert hierarchical groups to TanStack Table's expandable row format
|
||||
*
|
||||
* Transforms nested GroupedItem structure into flat ExpandableTableRow array
|
||||
* that TanStack Table can use with its native expanding feature.
|
||||
*
|
||||
* Key behaviors:
|
||||
* - Serie level (level -1) and breakdown levels 0 to breakdownCount-2 create group headers
|
||||
* - Last breakdown level (breakdownCount-1) does NOT create group headers (always individual rows)
|
||||
* - Individual rows are explicitly marked as NOT group headers or summary rows
|
||||
*/
|
||||
export function groupsToExpandableRows(
|
||||
groups: Array<GroupedItem<TableRow>>,
|
||||
breakdownCount: number,
|
||||
): ExpandableTableRow[] {
|
||||
const result: ExpandableTableRow[] = [];
|
||||
|
||||
function processGroup(
|
||||
group: GroupedItem<TableRow>,
|
||||
parentPath: string[] = [],
|
||||
): ExpandableTableRow[] {
|
||||
const currentPath = [...parentPath, group.group];
|
||||
const subRows: ExpandableTableRow[] = [];
|
||||
|
||||
// Separate nested groups from individual data items
|
||||
const nestedGroups: GroupedItem<TableRow>[] = [];
|
||||
const individualItems: TableRow[] = [];
|
||||
|
||||
for (const item of group.items) {
|
||||
if (item && typeof item === 'object' && 'items' in item) {
|
||||
nestedGroups.push(item);
|
||||
} else if (item) {
|
||||
individualItems.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
// Process nested groups recursively (they become expandable group headers)
|
||||
for (const nestedGroup of nestedGroups) {
|
||||
subRows.push(...processGroup(nestedGroup, currentPath));
|
||||
}
|
||||
|
||||
// Process individual data items (leaf nodes)
|
||||
individualItems.forEach((item, index) => {
|
||||
// Build breakdownDisplay: first row shows all values, subsequent rows show parent path + item values
|
||||
const breakdownDisplay: (string | null)[] = [];
|
||||
const breakdownValues = item.breakdownValues;
|
||||
|
||||
for (let i = 0; i < breakdownCount; i++) {
|
||||
if (index === 0) {
|
||||
// First row: show all breakdown values
|
||||
breakdownDisplay.push(breakdownValues[i] ?? null);
|
||||
} else {
|
||||
// Subsequent rows: show parent path values, then item values
|
||||
if (i < currentPath.length) {
|
||||
breakdownDisplay.push(currentPath[i] ?? null);
|
||||
} else if (i < breakdownValues.length) {
|
||||
breakdownDisplay.push(breakdownValues[i] ?? null);
|
||||
} else {
|
||||
breakdownDisplay.push(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
subRows.push({
|
||||
...item,
|
||||
breakdownDisplay,
|
||||
groupKey: group.groupKey,
|
||||
parentGroupKey: group.parentGroupKey,
|
||||
isGroupHeader: false,
|
||||
isSummaryRow: false,
|
||||
});
|
||||
});
|
||||
|
||||
// If this group has subRows and is not the last breakdown level, create a group header row
|
||||
// Don't create group headers for the last breakdown level (level === breakdownCount - 1)
|
||||
// because the last breakdown should always be individual rows
|
||||
// -1 is serie level (should be grouped)
|
||||
// 0 to breakdownCount-2 are breakdown levels (should be grouped)
|
||||
// breakdownCount-1 is the last breakdown level (should NOT be grouped, always individual)
|
||||
const shouldCreateGroupHeader =
|
||||
subRows.length > 0 &&
|
||||
(group.level === -1 || group.level < breakdownCount - 1);
|
||||
|
||||
if (shouldCreateGroupHeader) {
|
||||
// Create a summary row for the group
|
||||
const groupItems = flattenGroupedItems(group.items);
|
||||
const summaryRow = createSummaryRow(
|
||||
groupItems,
|
||||
group.groupKey,
|
||||
breakdownCount,
|
||||
);
|
||||
|
||||
return [
|
||||
{
|
||||
...summaryRow,
|
||||
isGroupHeader: true,
|
||||
groupValue: group.group,
|
||||
groupLevel: group.level,
|
||||
subRows,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return subRows;
|
||||
}
|
||||
|
||||
for (const group of groups) {
|
||||
result.push(...processGroup(group));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert hierarchical groups to flat table rows, respecting collapsed groups
|
||||
* This creates GroupedTableRow entries with proper breakdownDisplay values
|
||||
* @deprecated Use groupsToExpandableRows with TanStack Table's expanding feature instead
|
||||
*/
|
||||
export function groupsToTableRows<T extends TableRow>(
|
||||
groups: Array<GroupedItem<T>>,
|
||||
collapsedGroups: Set<string>,
|
||||
breakdownCount: number,
|
||||
): GroupedTableRow[] {
|
||||
const rows: GroupedTableRow[] = [];
|
||||
|
||||
function processGroup(
|
||||
group: GroupedItem<T>,
|
||||
parentPath: string[] = [],
|
||||
parentGroupKey?: string,
|
||||
): void {
|
||||
const isGroupCollapsed = collapsedGroups.has(group.groupKey);
|
||||
const currentPath = [...parentPath, group.group];
|
||||
|
||||
if (isGroupCollapsed) {
|
||||
// Group is collapsed - add summary row
|
||||
const groupItems = flattenGroupedItems(group.items);
|
||||
if (groupItems.length > 0) {
|
||||
const summaryRow = createSummaryRow(
|
||||
groupItems,
|
||||
group.groupKey,
|
||||
breakdownCount,
|
||||
);
|
||||
rows.push(summaryRow);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Group is expanded - process items
|
||||
// Separate nested groups from actual items
|
||||
const nestedGroups: GroupedItem<T>[] = [];
|
||||
const actualItems: T[] = [];
|
||||
|
||||
for (const item of group.items) {
|
||||
if (item && typeof item === 'object' && 'items' in item) {
|
||||
nestedGroups.push(item);
|
||||
} else if (item) {
|
||||
actualItems.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
// Process actual items first
|
||||
actualItems.forEach((item, index) => {
|
||||
const breakdownDisplay: (string | null)[] = [];
|
||||
const breakdownValues = item.breakdownValues;
|
||||
|
||||
// For the first item in the group, show all breakdown values
|
||||
// For subsequent items, show values based on hierarchy
|
||||
if (index === 0) {
|
||||
// First row shows all breakdown values
|
||||
for (let i = 0; i < breakdownCount; i++) {
|
||||
breakdownDisplay.push(breakdownValues[i] ?? null);
|
||||
}
|
||||
} else {
|
||||
// Subsequent rows: show values from parent path, then item values
|
||||
for (let i = 0; i < breakdownCount; i++) {
|
||||
if (i < currentPath.length) {
|
||||
// Show value from parent group path
|
||||
breakdownDisplay.push(currentPath[i] ?? null);
|
||||
} else if (i < breakdownValues.length) {
|
||||
// Show current breakdown value from the item
|
||||
breakdownDisplay.push(breakdownValues[i] ?? null);
|
||||
} else {
|
||||
breakdownDisplay.push(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rows.push({
|
||||
...item,
|
||||
breakdownDisplay,
|
||||
groupKey: group.groupKey,
|
||||
parentGroupKey: group.parentGroupKey,
|
||||
});
|
||||
});
|
||||
|
||||
// Process nested groups
|
||||
for (const nestedGroup of nestedGroups) {
|
||||
processGroup(nestedGroup, currentPath, group.groupKey);
|
||||
}
|
||||
}
|
||||
|
||||
for (const group of groups) {
|
||||
processGroup(group);
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract unique dates from all series
|
||||
*/
|
||||
function getUniqueDates(series: IChartData['series']): string[] {
|
||||
const dateSet = new Set<string>();
|
||||
series.forEach((serie) => {
|
||||
serie.data.forEach((d) => {
|
||||
dateSet.add(d.date);
|
||||
});
|
||||
});
|
||||
return Array.from(dateSet).sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get breakdown property names from series
|
||||
* Breakdown values are in names.slice(1), so we need to infer the property names
|
||||
* from the breakdowns array or from the series structure
|
||||
*/
|
||||
function getBreakdownPropertyNames(
|
||||
series: IChartData['series'],
|
||||
breakdowns: Array<{ name: string }>,
|
||||
): string[] {
|
||||
// If we have breakdowns from state, use those
|
||||
if (breakdowns.length > 0) {
|
||||
return breakdowns.map((b) => getPropertyLabel(b.name));
|
||||
}
|
||||
|
||||
// Otherwise, infer from series names
|
||||
// All series should have the same number of breakdown values
|
||||
if (series.length === 0) return [];
|
||||
const firstSerie = series[0];
|
||||
const breakdownCount = firstSerie.names.length - 1;
|
||||
return Array.from({ length: breakdownCount }, (_, i) => `Breakdown ${i + 1}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform series into flat table rows
|
||||
*/
|
||||
export function createFlatRows(
|
||||
series: IChartData['series'],
|
||||
dates: string[],
|
||||
): TableRow[] {
|
||||
return series.map((serie) => {
|
||||
const dateValues: Record<string, number> = {};
|
||||
dates.forEach((date) => {
|
||||
const dataPoint = serie.data.find((d) => d.date === date);
|
||||
dateValues[date] = dataPoint?.count ?? 0;
|
||||
});
|
||||
|
||||
return {
|
||||
id: serie.id,
|
||||
serieId: serie.id,
|
||||
serieName: serie.names[0] ?? '',
|
||||
breakdownValues: serie.names.slice(1),
|
||||
count: serie.metrics.count ?? 0,
|
||||
sum: serie.metrics.sum,
|
||||
average: serie.metrics.average,
|
||||
min: serie.metrics.min,
|
||||
max: serie.metrics.max,
|
||||
dateValues,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform series into hierarchical groups
|
||||
* Uses the new groupByNames function for better structure
|
||||
* Groups by serie name first, then by breakdown values
|
||||
*/
|
||||
export function createGroupedRowsHierarchical(
|
||||
series: IChartData['series'],
|
||||
dates: string[],
|
||||
): Array<GroupedItem<TableRow>> {
|
||||
const flatRows = createFlatRows(series, dates);
|
||||
|
||||
// Sort by sum descending before grouping
|
||||
flatRows.sort((a, b) => b.sum - a.sum);
|
||||
|
||||
const breakdownCount = flatRows[0]?.breakdownValues.length ?? 0;
|
||||
|
||||
if (breakdownCount === 0) {
|
||||
// No breakdowns - return empty array (will be handled as flat rows)
|
||||
return [];
|
||||
}
|
||||
|
||||
// Create hierarchical groups using groupByNames
|
||||
// Note: groupByNames expects items with a `names` array, so we create a temporary array
|
||||
// This is a minor inefficiency but keeps groupByNames generic and reusable
|
||||
const itemsWithNames = flatRows.map((row) => ({
|
||||
...row,
|
||||
names: [row.serieName, ...row.breakdownValues],
|
||||
}));
|
||||
|
||||
return groupByNames(itemsWithNames);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform series into grouped table rows (legacy flat format)
|
||||
* Groups rows hierarchically by breakdown values
|
||||
* @deprecated Use createGroupedRowsHierarchical + groupsToTableRows instead
|
||||
*/
|
||||
export function createGroupedRows(
|
||||
series: IChartData['series'],
|
||||
dates: string[],
|
||||
): GroupedTableRow[] {
|
||||
const flatRows = createFlatRows(series, dates);
|
||||
|
||||
// Sort by sum descending
|
||||
flatRows.sort((a, b) => b.sum - a.sum);
|
||||
|
||||
// Group rows by breakdown values hierarchically
|
||||
const grouped: GroupedTableRow[] = [];
|
||||
const breakdownCount = flatRows[0]?.breakdownValues.length ?? 0;
|
||||
|
||||
if (breakdownCount === 0) {
|
||||
// No breakdowns, just return flat rows
|
||||
return flatRows.map((row) => ({
|
||||
...row,
|
||||
breakdownDisplay: [],
|
||||
}));
|
||||
}
|
||||
|
||||
// Group rows hierarchically by breakdown values
|
||||
// We need to group by parent breakdowns first, then by child breakdowns
|
||||
// This creates the nested structure shown in the user's example
|
||||
|
||||
// First, group by first breakdown value
|
||||
const groupsByFirstBreakdown = new Map<string, TableRow[]>();
|
||||
flatRows.forEach((row) => {
|
||||
const firstBreakdown = row.breakdownValues[0] ?? '';
|
||||
if (!groupsByFirstBreakdown.has(firstBreakdown)) {
|
||||
groupsByFirstBreakdown.set(firstBreakdown, []);
|
||||
}
|
||||
groupsByFirstBreakdown.get(firstBreakdown)!.push(row);
|
||||
});
|
||||
|
||||
// Sort groups by sum of highest row in group
|
||||
const sortedGroups = Array.from(groupsByFirstBreakdown.entries()).sort(
|
||||
(a, b) => {
|
||||
const aMax = Math.max(...a[1].map((r) => r.sum));
|
||||
const bMax = Math.max(...b[1].map((r) => r.sum));
|
||||
return bMax - aMax;
|
||||
},
|
||||
);
|
||||
|
||||
// Process each group hierarchically
|
||||
sortedGroups.forEach(([firstBreakdownValue, groupRows]) => {
|
||||
// Within each first-breakdown group, sort by sum
|
||||
groupRows.sort((a, b) => b.sum - a.sum);
|
||||
|
||||
// Generate group key for this first-breakdown group
|
||||
const groupKey = firstBreakdownValue;
|
||||
|
||||
// For each row in the group
|
||||
groupRows.forEach((row, index) => {
|
||||
const breakdownDisplay: (string | null)[] = [];
|
||||
const firstRow = groupRows[0]!;
|
||||
|
||||
if (index === 0) {
|
||||
// First row shows all breakdown values
|
||||
breakdownDisplay.push(...row.breakdownValues);
|
||||
} else {
|
||||
// Subsequent rows: show all values, but mark duplicates for muted styling
|
||||
for (let i = 0; i < row.breakdownValues.length; i++) {
|
||||
// Always show the value, even if it matches the first row
|
||||
breakdownDisplay.push(row.breakdownValues[i] ?? null);
|
||||
}
|
||||
}
|
||||
|
||||
grouped.push({
|
||||
...row,
|
||||
breakdownDisplay,
|
||||
groupKey,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return grouped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a summary row for a collapsed group
|
||||
*/
|
||||
export function createSummaryRow(
|
||||
groupRows: TableRow[],
|
||||
groupKey: string,
|
||||
breakdownCount: number,
|
||||
): GroupedTableRow {
|
||||
const firstRow = groupRows[0]!;
|
||||
|
||||
// Aggregate metrics from all rows in the group
|
||||
const totalSum = groupRows.reduce((sum, row) => sum + row.sum, 0);
|
||||
const totalCount = groupRows.reduce((sum, row) => sum + row.count, 0);
|
||||
const totalAverage =
|
||||
groupRows.reduce((sum, row) => sum + row.average, 0) / groupRows.length;
|
||||
const totalMin = Math.min(...groupRows.map((row) => row.min));
|
||||
const totalMax = Math.max(...groupRows.map((row) => row.max));
|
||||
|
||||
// Aggregate date values across all rows
|
||||
const dateValues: Record<string, number> = {};
|
||||
groupRows.forEach((row) => {
|
||||
Object.keys(row.dateValues).forEach((date) => {
|
||||
dateValues[date] = (dateValues[date] ?? 0) + row.dateValues[date];
|
||||
});
|
||||
});
|
||||
|
||||
// Build breakdownDisplay: show first breakdown value, rest are null
|
||||
const breakdownDisplay: (string | null)[] = [
|
||||
firstRow.breakdownValues[0] ?? null,
|
||||
...Array(breakdownCount - 1).fill(null),
|
||||
];
|
||||
|
||||
return {
|
||||
id: `summary-${groupKey}`,
|
||||
serieId: firstRow.serieId,
|
||||
serieName: firstRow.serieName,
|
||||
breakdownValues: firstRow.breakdownValues,
|
||||
count: totalCount,
|
||||
sum: totalSum,
|
||||
average: totalAverage,
|
||||
min: totalMin,
|
||||
max: totalMax,
|
||||
dateValues,
|
||||
groupKey,
|
||||
isSummaryRow: true,
|
||||
breakdownDisplay,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorder breakdowns by number of unique values (fewest first)
|
||||
*/
|
||||
function reorderBreakdownsByUniqueCount(
|
||||
series: IChartData['series'],
|
||||
breakdownPropertyNames: string[],
|
||||
): {
|
||||
reorderedNames: string[];
|
||||
reorderMap: number[]; // Maps new index -> old index
|
||||
reverseMap: number[]; // Maps old index -> new index
|
||||
} {
|
||||
if (breakdownPropertyNames.length === 0 || series.length === 0) {
|
||||
return {
|
||||
reorderedNames: breakdownPropertyNames,
|
||||
reorderMap: [],
|
||||
reverseMap: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Count unique values for each breakdown index
|
||||
const uniqueCounts = breakdownPropertyNames.map((_, index) => {
|
||||
const uniqueValues = new Set<string>();
|
||||
series.forEach((serie) => {
|
||||
const value = serie.names[index + 1]; // +1 because names[0] is serie name
|
||||
if (value) {
|
||||
uniqueValues.add(value);
|
||||
}
|
||||
});
|
||||
return { index, count: uniqueValues.size };
|
||||
});
|
||||
|
||||
// Sort by count (ascending - fewest first)
|
||||
uniqueCounts.sort((a, b) => a.count - b.count);
|
||||
|
||||
// Create reordered names and mapping
|
||||
const reorderedNames = uniqueCounts.map(
|
||||
(item) => breakdownPropertyNames[item.index]!,
|
||||
);
|
||||
const reorderMap = uniqueCounts.map((item) => item.index); // new index -> old index
|
||||
const reverseMap = new Array(breakdownPropertyNames.length);
|
||||
reorderMap.forEach((oldIndex, newIndex) => {
|
||||
reverseMap[oldIndex] = newIndex;
|
||||
});
|
||||
|
||||
return { reorderedNames, reorderMap, reverseMap };
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform chart data into table-ready format
|
||||
*/
|
||||
export function transformToTableData(
|
||||
data: IChartData,
|
||||
breakdowns: Array<{ name: string }>,
|
||||
grouped: boolean,
|
||||
): {
|
||||
rows: TableRow[] | GroupedTableRow[];
|
||||
dates: string[];
|
||||
breakdownPropertyNames: string[];
|
||||
} {
|
||||
const dates = getUniqueDates(data.series);
|
||||
const originalBreakdownPropertyNames = getBreakdownPropertyNames(
|
||||
data.series,
|
||||
breakdowns,
|
||||
);
|
||||
|
||||
// Reorder breakdowns by unique count (fewest first)
|
||||
const { reorderedNames: breakdownPropertyNames, reorderMap } =
|
||||
reorderBreakdownsByUniqueCount(data.series, originalBreakdownPropertyNames);
|
||||
|
||||
// Reorder breakdown values in series before creating rows
|
||||
const reorderedSeries = data.series.map((serie) => {
|
||||
const reorderedNames = [
|
||||
serie.names[0], // Keep serie name first
|
||||
...reorderMap.map((oldIndex) => serie.names[oldIndex + 1] ?? ''), // Reorder breakdown values
|
||||
];
|
||||
return {
|
||||
...serie,
|
||||
names: reorderedNames,
|
||||
};
|
||||
});
|
||||
|
||||
const rows = grouped
|
||||
? createGroupedRows(reorderedSeries, dates)
|
||||
: createFlatRows(reorderedSeries, dates);
|
||||
|
||||
// Sort flat rows by sum descending
|
||||
if (!grouped) {
|
||||
(rows as TableRow[]).sort((a, b) => b.sum - a.sum);
|
||||
}
|
||||
|
||||
return {
|
||||
rows,
|
||||
dates,
|
||||
breakdownPropertyNames,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform chart data into hierarchical groups
|
||||
* Returns hierarchical structure for better group management
|
||||
*/
|
||||
export function transformToHierarchicalGroups(
|
||||
data: IChartData,
|
||||
breakdowns: Array<{ name: string }>,
|
||||
): {
|
||||
groups: Array<GroupedItem<TableRow>>;
|
||||
dates: string[];
|
||||
breakdownPropertyNames: string[];
|
||||
} {
|
||||
const dates = getUniqueDates(data.series);
|
||||
const originalBreakdownPropertyNames = getBreakdownPropertyNames(
|
||||
data.series,
|
||||
breakdowns,
|
||||
);
|
||||
|
||||
// Reorder breakdowns by unique count (fewest first)
|
||||
const { reorderedNames: breakdownPropertyNames, reorderMap } =
|
||||
reorderBreakdownsByUniqueCount(data.series, originalBreakdownPropertyNames);
|
||||
|
||||
// Reorder breakdown values in series before creating rows
|
||||
const reorderedSeries = data.series.map((serie) => {
|
||||
const reorderedNames = [
|
||||
serie.names[0], // Keep serie name first
|
||||
...reorderMap.map((oldIndex) => serie.names[oldIndex + 1] ?? ''), // Reorder breakdown values
|
||||
];
|
||||
return {
|
||||
...serie,
|
||||
names: reorderedNames,
|
||||
};
|
||||
});
|
||||
|
||||
const groups = createGroupedRowsHierarchical(reorderedSeries, dates);
|
||||
|
||||
return {
|
||||
groups,
|
||||
dates,
|
||||
breakdownPropertyNames,
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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'];
|
||||
@@ -30,20 +40,12 @@ interface Props {
|
||||
|
||||
export function Chart({ data }: Props) {
|
||||
const {
|
||||
report: {
|
||||
previous,
|
||||
interval,
|
||||
projectId,
|
||||
startDate,
|
||||
endDate,
|
||||
range,
|
||||
lineType,
|
||||
events,
|
||||
},
|
||||
report: { interval, projectId, startDate, endDate, range, lineType },
|
||||
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(
|
||||
@@ -65,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;
|
||||
@@ -88,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 (
|
||||
<TooltipProvider conversion={data} interval={interval}>
|
||||
<TooltipProvider
|
||||
conversion={data}
|
||||
interval={interval}
|
||||
visibleSeries={series}
|
||||
>
|
||||
<div className={cn('h-full w-full', isEditMode && 'card p-4')}>
|
||||
<ResponsiveContainer>
|
||||
<LineChart data={rechartData} onClick={handleChartClick}>
|
||||
@@ -116,35 +139,48 @@ export function Chart({ data }: Props) {
|
||||
))}
|
||||
<YAxis {...yAxisProps} domain={[0, 100]} />
|
||||
<XAxis {...xAxisProps} allowDuplicatedCategory={false} />
|
||||
{series.length > 1 && <Legend content={<CustomLegend />} />}
|
||||
<Tooltip />
|
||||
<Line
|
||||
dot={false}
|
||||
dataKey="previousRate"
|
||||
stroke={getChartColor(0)}
|
||||
type={lineType}
|
||||
isAnimationActive={false}
|
||||
strokeWidth={1}
|
||||
strokeOpacity={0.5}
|
||||
/>
|
||||
<Line
|
||||
dataKey="rate"
|
||||
stroke={getChartColor(0)}
|
||||
type={lineType}
|
||||
isAnimationActive={false}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
{series.map((serie) => {
|
||||
const color = getChartColor(serie.index);
|
||||
return (
|
||||
<Line
|
||||
key={`${serie.id}:previousRate`}
|
||||
dot={false}
|
||||
dataKey={`${serie.id}:previousRate`}
|
||||
stroke={color}
|
||||
type={lineType}
|
||||
isAnimationActive={false}
|
||||
strokeWidth={1}
|
||||
strokeOpacity={0.3}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{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' &&
|
||||
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,
|
||||
}}
|
||||
@@ -153,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) }}
|
||||
/>
|
||||
<div className="col flex-1 gap-1">
|
||||
<div className="flex items-center gap-1">{title}</div>
|
||||
<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']
|
||||
}
|
||||
/>
|
||||
<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>
|
||||
</div>
|
||||
</ChartTooltipItem>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,515 @@
|
||||
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 type { SortingState } from '@tanstack/react-table';
|
||||
import { useMemo, useState } 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 [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [globalFilter, setGlobalFilter] = useState('');
|
||||
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];
|
||||
});
|
||||
};
|
||||
|
||||
// Filter and sort rows
|
||||
const filteredAndSortedRows = useMemo(() => {
|
||||
let result = rows;
|
||||
|
||||
// Apply search filter
|
||||
if (globalFilter.trim()) {
|
||||
const searchLower = globalFilter.toLowerCase();
|
||||
result = rows.filter((row) => {
|
||||
// Search in serie name
|
||||
if (
|
||||
row.serieName.some((name) =>
|
||||
name?.toLowerCase().includes(searchLower),
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Search in breakdown values
|
||||
if (
|
||||
row.breakdownValues.some((val) =>
|
||||
val?.toLowerCase().includes(searchLower),
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Search in metric values
|
||||
if (
|
||||
String(row.avgRate).toLowerCase().includes(searchLower) ||
|
||||
String(row.total).toLowerCase().includes(searchLower) ||
|
||||
String(row.conversions).toLowerCase().includes(searchLower)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Search in date values
|
||||
if (
|
||||
Object.values(row.dateValues).some((val) =>
|
||||
String(val).toLowerCase().includes(searchLower),
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
// Apply sorting
|
||||
if (sorting.length > 0) {
|
||||
result = [...result].sort((a, b) => {
|
||||
for (const sort of sorting) {
|
||||
const { id, desc } = sort;
|
||||
let aValue: any;
|
||||
let bValue: any;
|
||||
|
||||
if (id === 'serie-name') {
|
||||
aValue = a.serieName.join(' > ') ?? '';
|
||||
bValue = b.serieName.join(' > ') ?? '';
|
||||
} else if (id === 'metric-avgRate') {
|
||||
aValue = a.avgRate ?? 0;
|
||||
bValue = b.avgRate ?? 0;
|
||||
} else if (id === 'metric-total') {
|
||||
aValue = a.total ?? 0;
|
||||
bValue = b.total ?? 0;
|
||||
} else if (id === 'metric-conversions') {
|
||||
aValue = a.conversions ?? 0;
|
||||
bValue = b.conversions ?? 0;
|
||||
} else if (id.startsWith('date-')) {
|
||||
const date = id.replace('date-', '');
|
||||
aValue = a.dateValues[date] ?? 0;
|
||||
bValue = b.dateValues[date] ?? 0;
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle null/undefined values
|
||||
if (aValue == null && bValue == null) continue;
|
||||
if (aValue == null) return 1;
|
||||
if (bValue == null) return -1;
|
||||
|
||||
// Compare values
|
||||
if (typeof aValue === 'string' && typeof bValue === 'string') {
|
||||
const comparison = aValue.localeCompare(bValue);
|
||||
if (comparison !== 0) return desc ? -comparison : comparison;
|
||||
} else {
|
||||
if (aValue < bValue) return desc ? 1 : -1;
|
||||
if (aValue > bValue) return desc ? -1 : 1;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [rows, globalFilter, sorting]);
|
||||
|
||||
const handleSort = (columnId: string) => {
|
||||
setSorting((prev) => {
|
||||
const existingSort = prev.find((s) => s.id === columnId);
|
||||
if (existingSort) {
|
||||
if (existingSort.desc) {
|
||||
// Toggle to ascending if already descending
|
||||
return [{ id: columnId, desc: false }];
|
||||
}
|
||||
// Remove sort if already ascending
|
||||
return [];
|
||||
}
|
||||
// Start with descending (highest first)
|
||||
return [{ id: columnId, desc: true }];
|
||||
});
|
||||
};
|
||||
|
||||
const getSortIcon = (columnId: string) => {
|
||||
const sort = sorting.find((s) => s.id === columnId);
|
||||
if (!sort) return '⇅';
|
||||
return sort.desc ? '↓' : '↑';
|
||||
};
|
||||
|
||||
if (allSeries.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col border rounded-lg overflow-hidden bg-card mt-8">
|
||||
<ReportTableToolbar
|
||||
search={globalFilter}
|
||||
onSearchChange={setGlobalFilter}
|
||||
onUnselectAll={() => setVisibleSeries([])}
|
||||
/>
|
||||
<div
|
||||
className="overflow-x-auto overflow-y-auto"
|
||||
style={{
|
||||
width: '100%',
|
||||
maxHeight: '600px',
|
||||
}}
|
||||
>
|
||||
<table className="w-full" style={{ minWidth: 'fit-content' }}>
|
||||
<thead className="bg-muted/30 border-b sticky top-0 z-10">
|
||||
<tr>
|
||||
<th
|
||||
className="text-left h-10 px-4 text-[10px] uppercase font-semibold sticky left-0 bg-card z-20 min-w-[200px] border-r border-border whitespace-nowrap"
|
||||
style={{
|
||||
boxShadow: '2px 0 4px -2px var(--border)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center">Serie</div>
|
||||
</th>
|
||||
<th
|
||||
className="text-right h-10 px-4 text-[10px] uppercase font-semibold min-w-[100px] cursor-pointer hover:bg-muted/50 select-none border-r border-border whitespace-nowrap"
|
||||
onClick={() => handleSort('metric-avgRate')}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleSort('metric-avgRate');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
Avg Rate
|
||||
<span className="text-muted-foreground">
|
||||
{getSortIcon('metric-avgRate')}
|
||||
</span>
|
||||
</div>
|
||||
</th>
|
||||
<th
|
||||
className="text-right h-10 px-4 text-[10px] uppercase font-semibold min-w-[100px] cursor-pointer hover:bg-muted/50 select-none border-r border-border whitespace-nowrap"
|
||||
onClick={() => handleSort('metric-total')}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleSort('metric-total');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
Total
|
||||
<span className="text-muted-foreground">
|
||||
{getSortIcon('metric-total')}
|
||||
</span>
|
||||
</div>
|
||||
</th>
|
||||
<th
|
||||
className="text-right h-10 px-4 text-[10px] uppercase font-semibold min-w-[100px] cursor-pointer hover:bg-muted/50 select-none border-r border-border whitespace-nowrap"
|
||||
onClick={() => handleSort('metric-conversions')}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleSort('metric-conversions');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
Conversions
|
||||
<span className="text-muted-foreground">
|
||||
{getSortIcon('metric-conversions')}
|
||||
</span>
|
||||
</div>
|
||||
</th>
|
||||
{dates.map((date) => (
|
||||
<th
|
||||
key={date}
|
||||
className="text-right h-10 px-4 text-[10px] uppercase font-semibold min-w-[100px] cursor-pointer hover:bg-muted/50 select-none border-r border-border whitespace-nowrap"
|
||||
onClick={() => handleSort(`date-${date}`)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleSort(`date-${date}`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-end gap-1.5">
|
||||
{formatDate(date)}
|
||||
<span className="text-muted-foreground">
|
||||
{getSortIcon(`date-${date}`)}
|
||||
</span>
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredAndSortedRows.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 z-10 border-r border-border"
|
||||
style={{
|
||||
backgroundColor: 'var(--card)',
|
||||
boxShadow: '2px 0 4px -2px var(--border)',
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import { Summary } from './summary';
|
||||
export function ReportConversionChart() {
|
||||
const { isLazyLoading, report } = useReportChartContext();
|
||||
const trpc = useTRPC();
|
||||
|
||||
console.log(report.limit);
|
||||
const res = useQuery(
|
||||
trpc.chart.conversion.queryOptions(report, {
|
||||
placeholderData: keepPreviousData,
|
||||
|
||||
@@ -144,14 +144,16 @@ export function Summary({ data }: Props) {
|
||||
title="Flow"
|
||||
value={
|
||||
<div className="row flex-wrap gap-1">
|
||||
{report.events.map((event, index) => {
|
||||
return (
|
||||
<div key={event.id} className="row items-center gap-2">
|
||||
{index !== 0 && <ChevronRightIcon className="size-3" />}
|
||||
<span>{event.name}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{report.series
|
||||
.filter((item) => item.type === 'event')
|
||||
.map((event, index) => {
|
||||
return (
|
||||
<div key={event.id} className="row items-center gap-2">
|
||||
{index !== 0 && <ChevronRightIcon className="size-3" />}
|
||||
<span>{event.name}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { ColorSquare } from '@/components/color-square';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { pushModal } from '@/modals';
|
||||
import type { RouterOutputs } from '@/trpc/client';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { ChevronRightIcon, InfoIcon } from 'lucide-react';
|
||||
import { ChevronRightIcon, InfoIcon, UsersIcon } from 'lucide-react';
|
||||
|
||||
import { alphabetIds } from '@openpanel/constants';
|
||||
|
||||
@@ -23,6 +25,7 @@ import {
|
||||
} from 'recharts';
|
||||
import { useXAxisProps, useYAxisProps } from '../common/axis';
|
||||
import { PreviousDiffIndicatorPure } from '../common/previous-diff-indicator';
|
||||
import { useReportChartContext } from '../context';
|
||||
|
||||
type Props = {
|
||||
data: {
|
||||
@@ -113,11 +116,50 @@ function ChartName({
|
||||
export function Tables({
|
||||
data: {
|
||||
current: { steps, mostDropoffsStep, lastStep, breakdowns },
|
||||
previous,
|
||||
previous: previousData,
|
||||
},
|
||||
}: Props) {
|
||||
const number = useNumber();
|
||||
const hasHeader = breakdowns.length > 0;
|
||||
const {
|
||||
report: {
|
||||
projectId,
|
||||
startDate,
|
||||
endDate,
|
||||
range,
|
||||
interval,
|
||||
series: reportSeries,
|
||||
breakdowns: reportBreakdowns,
|
||||
previous,
|
||||
funnelWindow,
|
||||
funnelGroup,
|
||||
},
|
||||
} = useReportChartContext();
|
||||
|
||||
const handleInspectStep = (step: (typeof steps)[0], stepIndex: number) => {
|
||||
if (!projectId || !step.event.id) return;
|
||||
|
||||
// For funnels, we need to pass the step index so the modal can query
|
||||
// users who completed at least that step in the funnel sequence
|
||||
pushModal('ViewChartUsers', {
|
||||
type: 'funnel',
|
||||
report: {
|
||||
projectId,
|
||||
series: reportSeries,
|
||||
breakdowns: reportBreakdowns || [],
|
||||
interval: interval || 'day',
|
||||
startDate,
|
||||
endDate,
|
||||
range,
|
||||
previous,
|
||||
chartType: 'funnel',
|
||||
metric: 'sum',
|
||||
funnelWindow,
|
||||
funnelGroup,
|
||||
},
|
||||
stepIndex, // Pass the step index for funnel queries
|
||||
});
|
||||
};
|
||||
return (
|
||||
<div className={cn('col @container divide-y divide-border card')}>
|
||||
{hasHeader && <ChartName breakdowns={breakdowns} className="p-4 py-3" />}
|
||||
@@ -128,11 +170,11 @@ export function Tables({
|
||||
label="Conversion"
|
||||
value={number.formatWithUnit(lastStep?.percent / 100, '%')}
|
||||
enhancer={
|
||||
previous && (
|
||||
previousData && (
|
||||
<PreviousDiffIndicatorPure
|
||||
{...getPreviousMetric(
|
||||
lastStep?.percent,
|
||||
previous.lastStep?.percent,
|
||||
previousData.lastStep?.percent,
|
||||
)}
|
||||
/>
|
||||
)
|
||||
@@ -143,11 +185,11 @@ export function Tables({
|
||||
label="Completed"
|
||||
value={number.format(lastStep?.count)}
|
||||
enhancer={
|
||||
previous && (
|
||||
previousData && (
|
||||
<PreviousDiffIndicatorPure
|
||||
{...getPreviousMetric(
|
||||
lastStep?.count,
|
||||
previous.lastStep?.count,
|
||||
previousData.lastStep?.count,
|
||||
)}
|
||||
/>
|
||||
)
|
||||
@@ -238,6 +280,28 @@ export function Tables({
|
||||
className: 'text-right font-mono font-semibold',
|
||||
width: '90px',
|
||||
},
|
||||
{
|
||||
name: '',
|
||||
render: (item) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const stepIndex = steps.findIndex(
|
||||
(s) => s.event.id === item.event.id,
|
||||
);
|
||||
handleInspectStep(item, stepIndex);
|
||||
}}
|
||||
title="View users who completed this step"
|
||||
>
|
||||
<UsersIcon size={16} />
|
||||
</Button>
|
||||
),
|
||||
className: 'text-right',
|
||||
width: '48px',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
@@ -299,6 +363,7 @@ export function Chart({ data }: { data: RouterOutputs['chart']['funnel'] }) {
|
||||
const rechartData = useRechartData(data);
|
||||
const xAxisProps = useXAxisProps();
|
||||
const yAxisProps = useYAxisProps();
|
||||
const hasBreakdowns = data.current.length > 1;
|
||||
|
||||
return (
|
||||
<TooltipProvider data={data.current}>
|
||||
@@ -327,19 +392,37 @@ export function Chart({ data }: { data: RouterOutputs['chart']['funnel'] }) {
|
||||
}
|
||||
/>
|
||||
<YAxis {...yAxisProps} />
|
||||
<Bar
|
||||
data={rechartData}
|
||||
dataKey="step:percent:0"
|
||||
shape={<BarShapeProps />}
|
||||
>
|
||||
{rechartData.map((item, index) => (
|
||||
<Cell
|
||||
key={item.name}
|
||||
fill={getChartTranslucentColor(index)}
|
||||
stroke={getChartColor(index)}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
{hasBreakdowns ? (
|
||||
data.current.map((item, breakdownIndex) => (
|
||||
<Bar
|
||||
key={`step:percent:${item.id}`}
|
||||
dataKey={`step:percent:${breakdownIndex}`}
|
||||
shape={<BarShapeProps />}
|
||||
>
|
||||
{rechartData.map((item, stepIndex) => (
|
||||
<Cell
|
||||
key={`${item.name}-${breakdownIndex}`}
|
||||
fill={getChartTranslucentColor(breakdownIndex)}
|
||||
stroke={getChartColor(breakdownIndex)}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
))
|
||||
) : (
|
||||
<Bar
|
||||
data={rechartData}
|
||||
dataKey="step:percent:0"
|
||||
shape={<BarShapeProps />}
|
||||
>
|
||||
{rechartData.map((item, index) => (
|
||||
<Cell
|
||||
key={item.name}
|
||||
fill={getChartTranslucentColor(index)}
|
||||
stroke={getChartColor(index)}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
)}
|
||||
<Tooltip />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
@@ -348,8 +431,6 @@ export function Chart({ data }: { data: RouterOutputs['chart']['funnel'] }) {
|
||||
);
|
||||
}
|
||||
|
||||
type Hej = RouterOutputs['chart']['funnel']['current'];
|
||||
|
||||
const { Tooltip, TooltipProvider } = createChartTooltip<
|
||||
RechartData,
|
||||
{
|
||||
@@ -371,7 +452,7 @@ const { Tooltip, TooltipProvider } = createChartTooltip<
|
||||
<div className="flex justify-between gap-8 text-muted-foreground">
|
||||
<div>{data.name}</div>
|
||||
</div>
|
||||
{variants.map((key) => {
|
||||
{variants.map((key, breakdownIndex) => {
|
||||
const variant = data[key];
|
||||
const prevVariant = data[`prev_${key}`];
|
||||
if (!variant?.step) {
|
||||
@@ -381,7 +462,11 @@ const { Tooltip, TooltipProvider } = createChartTooltip<
|
||||
<div className="row gap-2" key={key}>
|
||||
<div
|
||||
className="w-[3px] rounded-full"
|
||||
style={{ background: getChartColor(index) }}
|
||||
style={{
|
||||
background: getChartColor(
|
||||
variants.length > 1 ? breakdownIndex : index,
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<div className="col flex-1 gap-1">
|
||||
<div className="flex items-center gap-1">
|
||||
|
||||
@@ -14,7 +14,7 @@ import { Chart, Summary, Tables } from './chart';
|
||||
export function ReportFunnelChart() {
|
||||
const {
|
||||
report: {
|
||||
events,
|
||||
series,
|
||||
range,
|
||||
projectId,
|
||||
funnelWindow,
|
||||
@@ -28,7 +28,7 @@ export function ReportFunnelChart() {
|
||||
} = useReportChartContext();
|
||||
|
||||
const input: IChartInput = {
|
||||
events,
|
||||
series,
|
||||
range,
|
||||
projectId,
|
||||
interval: 'day',
|
||||
@@ -40,11 +40,12 @@ export function ReportFunnelChart() {
|
||||
metric: 'sum',
|
||||
startDate,
|
||||
endDate,
|
||||
limit: 20,
|
||||
};
|
||||
const trpc = useTRPC();
|
||||
const res = useQuery(
|
||||
trpc.chart.funnel.queryOptions(input, {
|
||||
enabled: !isLazyLoading && input.events.length > 0,
|
||||
enabled: !isLazyLoading && input.series.length > 0,
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { IChartData } from '@/trpc/client';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { getChartColor } from '@/utils/theme';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { BookmarkIcon, UsersIcon } from 'lucide-react';
|
||||
import React, { useCallback } from 'react';
|
||||
import {
|
||||
Bar,
|
||||
@@ -20,6 +21,10 @@ import {
|
||||
} from 'recharts';
|
||||
|
||||
import { useXAxisProps, useYAxisProps } from '../common/axis';
|
||||
import {
|
||||
ChartClickMenu,
|
||||
type ChartClickMenuItem,
|
||||
} from '../common/chart-click-menu';
|
||||
import { ReportChartTooltip } from '../common/report-chart-tooltip';
|
||||
import { ReportTable } from '../common/report-table';
|
||||
import { useReportChartContext } from '../context';
|
||||
@@ -47,7 +52,16 @@ function BarHover({ x, y, width, height, top, left, right, bottom }: any) {
|
||||
export function Chart({ data }: Props) {
|
||||
const {
|
||||
isEditMode,
|
||||
report: { previous, interval, projectId, startDate, endDate, range },
|
||||
report: {
|
||||
previous,
|
||||
interval,
|
||||
projectId,
|
||||
startDate,
|
||||
endDate,
|
||||
range,
|
||||
series: reportSeries,
|
||||
breakdowns,
|
||||
},
|
||||
options: { hideXAxis, hideYAxis },
|
||||
} = useReportChartContext();
|
||||
const trpc = useTRPC();
|
||||
@@ -74,22 +88,73 @@ export function Chart({ data }: Props) {
|
||||
interval,
|
||||
});
|
||||
|
||||
const handleChartClick = useCallback((e: any) => {
|
||||
if (e?.activePayload?.[0]) {
|
||||
const clickedData = e.activePayload[0].payload;
|
||||
if (clickedData.date) {
|
||||
pushModal('AddReference', {
|
||||
datetime: new Date(clickedData.date).toISOString(),
|
||||
const getMenuItems = useCallback(
|
||||
(e: any, clickedData: any): ChartClickMenuItem[] => {
|
||||
const items: ChartClickMenuItem[] = [];
|
||||
|
||||
if (!clickedData?.date) {
|
||||
return items;
|
||||
}
|
||||
|
||||
// View Users - only show if we have projectId
|
||||
if (projectId) {
|
||||
items.push({
|
||||
label: 'View Users',
|
||||
icon: <UsersIcon size={16} />,
|
||||
onClick: () => {
|
||||
pushModal('ViewChartUsers', {
|
||||
type: 'chart',
|
||||
chartData: data,
|
||||
report: {
|
||||
projectId,
|
||||
series: reportSeries,
|
||||
breakdowns: breakdowns || [],
|
||||
interval,
|
||||
startDate,
|
||||
endDate,
|
||||
range,
|
||||
previous,
|
||||
chartType: 'histogram',
|
||||
metric: 'sum',
|
||||
},
|
||||
date: clickedData.date,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Add Reference - always show
|
||||
items.push({
|
||||
label: 'Add Reference',
|
||||
icon: <BookmarkIcon size={16} />,
|
||||
onClick: () => {
|
||||
pushModal('AddReference', {
|
||||
datetime: new Date(clickedData.date).toISOString(),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return items;
|
||||
},
|
||||
[
|
||||
projectId,
|
||||
data,
|
||||
reportSeries,
|
||||
breakdowns,
|
||||
interval,
|
||||
startDate,
|
||||
endDate,
|
||||
range,
|
||||
previous,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<ReportChartTooltip.TooltipProvider references={references.data}>
|
||||
<div className={cn('h-full w-full', isEditMode && 'card p-4')}>
|
||||
<ResponsiveContainer>
|
||||
<BarChart data={rechartData} onClick={handleChartClick}>
|
||||
<ChartClickMenu getMenuItems={getMenuItems}>
|
||||
<div className={cn('h-full w-full', isEditMode && 'card p-4')}>
|
||||
<ResponsiveContainer>
|
||||
<BarChart data={rechartData}>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
vertical={false}
|
||||
@@ -152,6 +217,7 @@ export function Chart({ data }: Props) {
|
||||
setVisibleSeries={setVisibleSeries}
|
||||
/>
|
||||
)}
|
||||
</ChartClickMenu>
|
||||
</ReportChartTooltip.TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { cn } from '@/utils/cn';
|
||||
import { getChartColor } from '@/utils/theme';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { isSameDay, isSameHour, isSameMonth, isSameWeek } from 'date-fns';
|
||||
import { BookmarkIcon, UsersIcon } from 'lucide-react';
|
||||
import { last } from 'ramda';
|
||||
import { useCallback } from 'react';
|
||||
import {
|
||||
@@ -24,6 +25,10 @@ import {
|
||||
|
||||
import { useDashedStroke } from '@/hooks/use-dashed-stroke';
|
||||
import { useXAxisProps, useYAxisProps } from '../common/axis';
|
||||
import {
|
||||
ChartClickMenu,
|
||||
type ChartClickMenuItem,
|
||||
} from '../common/chart-click-menu';
|
||||
import { ReportChartTooltip } from '../common/report-chart-tooltip';
|
||||
import { ReportTable } from '../common/report-table';
|
||||
import { SerieIcon } from '../common/serie-icon';
|
||||
@@ -44,6 +49,8 @@ export function Chart({ data }: Props) {
|
||||
endDate,
|
||||
range,
|
||||
lineType,
|
||||
series: reportSeries,
|
||||
breakdowns,
|
||||
},
|
||||
isEditMode,
|
||||
options: { hideXAxis, hideYAxis, maxDomain },
|
||||
@@ -128,81 +135,146 @@ export function Chart({ data }: Props) {
|
||||
hide: hideYAxis,
|
||||
});
|
||||
|
||||
const handleChartClick = useCallback((e: any) => {
|
||||
if (e?.activePayload?.[0]) {
|
||||
const clickedData = e.activePayload[0].payload;
|
||||
if (clickedData.date) {
|
||||
pushModal('AddReference', {
|
||||
datetime: new Date(clickedData.date).toISOString(),
|
||||
const getMenuItems = useCallback(
|
||||
(e: any, clickedData: any): ChartClickMenuItem[] => {
|
||||
const items: ChartClickMenuItem[] = [];
|
||||
|
||||
if (!clickedData?.date) {
|
||||
return items;
|
||||
}
|
||||
|
||||
// Extract serie ID from the click event if needed
|
||||
// activePayload is an array of payload objects
|
||||
const validPayload = e.activePayload?.find(
|
||||
(p: any) =>
|
||||
p.dataKey &&
|
||||
p.dataKey !== 'calcStrokeDasharray' &&
|
||||
typeof p.dataKey === 'string' &&
|
||||
p.dataKey.includes(':count'),
|
||||
);
|
||||
const serieId = validPayload?.dataKey?.toString().replace(':count', '');
|
||||
|
||||
// View Users - only show if we have projectId
|
||||
if (projectId) {
|
||||
items.push({
|
||||
label: 'View Users',
|
||||
icon: <UsersIcon size={16} />,
|
||||
onClick: () => {
|
||||
pushModal('ViewChartUsers', {
|
||||
type: 'chart',
|
||||
chartData: data,
|
||||
report: {
|
||||
projectId,
|
||||
series: reportSeries,
|
||||
breakdowns: breakdowns || [],
|
||||
interval,
|
||||
startDate,
|
||||
endDate,
|
||||
range,
|
||||
previous,
|
||||
chartType: 'linear',
|
||||
metric: 'sum',
|
||||
},
|
||||
date: clickedData.date,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Add Reference - always show
|
||||
items.push({
|
||||
label: 'Add Reference',
|
||||
icon: <BookmarkIcon size={16} />,
|
||||
onClick: () => {
|
||||
pushModal('AddReference', {
|
||||
datetime: new Date(clickedData.date).toISOString(),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return items;
|
||||
},
|
||||
[
|
||||
projectId,
|
||||
data,
|
||||
reportSeries,
|
||||
breakdowns,
|
||||
interval,
|
||||
startDate,
|
||||
endDate,
|
||||
range,
|
||||
previous,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<ReportChartTooltip.TooltipProvider references={references.data}>
|
||||
<div className={cn('h-full w-full', isEditMode && 'card p-4')}>
|
||||
<ResponsiveContainer>
|
||||
<ComposedChart data={rechartData} onClick={handleChartClick}>
|
||||
<Customized component={calcStrokeDasharray} />
|
||||
<Line
|
||||
dataKey="calcStrokeDasharray"
|
||||
legendType="none"
|
||||
animationDuration={0}
|
||||
onAnimationEnd={handleAnimationEnd}
|
||||
/>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
horizontal={true}
|
||||
vertical={false}
|
||||
className="stroke-border"
|
||||
/>
|
||||
{references.data?.map((ref) => (
|
||||
<ReferenceLine
|
||||
key={ref.id}
|
||||
x={ref.date.getTime()}
|
||||
stroke={'oklch(from var(--foreground) l c h / 0.1)'}
|
||||
strokeDasharray={'3 3'}
|
||||
label={{
|
||||
value: ref.title,
|
||||
position: 'centerTop',
|
||||
fill: '#334155',
|
||||
fontSize: 12,
|
||||
}}
|
||||
fontSize={10}
|
||||
<ChartClickMenu getMenuItems={getMenuItems}>
|
||||
<div className={cn('h-full w-full', isEditMode && 'card p-4')}>
|
||||
<ResponsiveContainer>
|
||||
<ComposedChart data={rechartData}>
|
||||
<Customized component={calcStrokeDasharray} />
|
||||
<Line
|
||||
dataKey="calcStrokeDasharray"
|
||||
legendType="none"
|
||||
animationDuration={0}
|
||||
onAnimationEnd={handleAnimationEnd}
|
||||
/>
|
||||
))}
|
||||
<YAxis
|
||||
{...yAxisProps}
|
||||
domain={maxDomain ? [0, maxDomain] : undefined}
|
||||
/>
|
||||
<XAxis {...xAxisProps} />
|
||||
{series.length > 1 && <Legend content={<CustomLegend />} />}
|
||||
<Tooltip content={<ReportChartTooltip.Tooltip />} />
|
||||
{/* {series.map((serie) => {
|
||||
const color = getChartColor(serie.index);
|
||||
return (
|
||||
<React.Fragment key={serie.id}>
|
||||
<defs>
|
||||
{isAreaStyle && (
|
||||
<linearGradient
|
||||
id={`color${color}`}
|
||||
x1="0"
|
||||
y1="0"
|
||||
x2="0"
|
||||
y2="1"
|
||||
>
|
||||
<stop offset="0%" stopColor={color} stopOpacity={0.8} />
|
||||
<stop
|
||||
offset="100%"
|
||||
stopColor={color}
|
||||
stopOpacity={0.1}
|
||||
/>
|
||||
</linearGradient>
|
||||
)}
|
||||
</defs>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
horizontal={true}
|
||||
vertical={false}
|
||||
className="stroke-border"
|
||||
/>
|
||||
{references.data?.map((ref) => (
|
||||
<ReferenceLine
|
||||
key={ref.id}
|
||||
x={ref.date.getTime()}
|
||||
stroke={'oklch(from var(--foreground) l c h / 0.1)'}
|
||||
strokeDasharray={'3 3'}
|
||||
label={{
|
||||
value: ref.title,
|
||||
position: 'centerTop',
|
||||
fill: '#334155',
|
||||
fontSize: 12,
|
||||
}}
|
||||
fontSize={10}
|
||||
/>
|
||||
))}
|
||||
<YAxis
|
||||
{...yAxisProps}
|
||||
domain={maxDomain ? [0, maxDomain] : undefined}
|
||||
/>
|
||||
<XAxis {...xAxisProps} />
|
||||
{series.length > 1 && <Legend content={<CustomLegend />} />}
|
||||
<Tooltip content={<ReportChartTooltip.Tooltip />} />
|
||||
|
||||
<defs>
|
||||
<filter
|
||||
id="rainbow-line-glow"
|
||||
x="-20%"
|
||||
y="-20%"
|
||||
width="140%"
|
||||
height="140%"
|
||||
>
|
||||
<feGaussianBlur stdDeviation="5" result="blur" />
|
||||
<feComponentTransfer in="blur" result="dimmedBlur">
|
||||
<feFuncA type="linear" slope="0.5" />
|
||||
</feComponentTransfer>
|
||||
<feComposite
|
||||
in="SourceGraphic"
|
||||
in2="dimmedBlur"
|
||||
operator="over"
|
||||
/>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
{series.map((serie) => {
|
||||
const color = getChartColor(serie.index);
|
||||
return (
|
||||
<Line
|
||||
dot={isAreaStyle && dataLength <= 8}
|
||||
key={serie.id}
|
||||
dot={dataLength <= 8}
|
||||
type={lineType}
|
||||
name={serie.id}
|
||||
isAnimationActive={false}
|
||||
@@ -216,100 +288,46 @@ export function Chart({ data }: Props) {
|
||||
}
|
||||
// Use for legend
|
||||
fill={color}
|
||||
filter={
|
||||
series.length === 1
|
||||
? 'url(#rainbow-line-glow)'
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
{previous && (
|
||||
<Line
|
||||
type={lineType}
|
||||
name={`${serie.id}:prev`}
|
||||
isAnimationActive
|
||||
dot={false}
|
||||
strokeOpacity={0.3}
|
||||
dataKey={`${serie.id}:prev:count`}
|
||||
stroke={color}
|
||||
// Use for legend
|
||||
fill={color}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})} */}
|
||||
);
|
||||
})}
|
||||
|
||||
<defs>
|
||||
<filter
|
||||
id="rainbow-line-glow"
|
||||
x="-20%"
|
||||
y="-20%"
|
||||
width="140%"
|
||||
height="140%"
|
||||
>
|
||||
<feGaussianBlur stdDeviation="5" result="blur" />
|
||||
<feComponentTransfer in="blur" result="dimmedBlur">
|
||||
<feFuncA type="linear" slope="0.5" />
|
||||
</feComponentTransfer>
|
||||
<feComposite
|
||||
in="SourceGraphic"
|
||||
in2="dimmedBlur"
|
||||
operator="over"
|
||||
/>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
{series.map((serie) => {
|
||||
const color = getChartColor(serie.index);
|
||||
return (
|
||||
<Line
|
||||
key={serie.id}
|
||||
dot={dataLength <= 8}
|
||||
type={lineType}
|
||||
name={serie.id}
|
||||
isAnimationActive={false}
|
||||
strokeWidth={2}
|
||||
dataKey={`${serie.id}:count`}
|
||||
stroke={color}
|
||||
strokeDasharray={
|
||||
useDashedLastLine
|
||||
? getStrokeDasharray(`${serie.id}:count`)
|
||||
: undefined
|
||||
}
|
||||
// Use for legend
|
||||
fill={color}
|
||||
filter={
|
||||
series.length === 1 ? 'url(#rainbow-line-glow)' : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Previous */}
|
||||
{previous
|
||||
? series.map((serie) => {
|
||||
const color = getChartColor(serie.index);
|
||||
return (
|
||||
<Line
|
||||
key={`${serie.id}:prev`}
|
||||
type={lineType}
|
||||
name={`${serie.id}:prev`}
|
||||
isAnimationActive
|
||||
dot={false}
|
||||
strokeOpacity={0.3}
|
||||
dataKey={`${serie.id}:prev:count`}
|
||||
stroke={color}
|
||||
// Use for legend
|
||||
fill={color}
|
||||
/>
|
||||
);
|
||||
})
|
||||
: null}
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
{isEditMode && (
|
||||
<ReportTable
|
||||
data={data}
|
||||
visibleSeries={series}
|
||||
setVisibleSeries={setVisibleSeries}
|
||||
/>
|
||||
)}
|
||||
{/* Previous */}
|
||||
{previous
|
||||
? series.map((serie) => {
|
||||
const color = getChartColor(serie.index);
|
||||
return (
|
||||
<Line
|
||||
key={`${serie.id}:prev`}
|
||||
type={lineType}
|
||||
name={`${serie.id}:prev`}
|
||||
isAnimationActive
|
||||
dot={false}
|
||||
strokeOpacity={0.3}
|
||||
dataKey={`${serie.id}:prev:count`}
|
||||
stroke={color}
|
||||
// Use for legend
|
||||
fill={color}
|
||||
/>
|
||||
);
|
||||
})
|
||||
: null}
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
{isEditMode && (
|
||||
<ReportTable
|
||||
data={data}
|
||||
visibleSeries={series}
|
||||
setVisibleSeries={setVisibleSeries}
|
||||
/>
|
||||
)}
|
||||
</ChartClickMenu>
|
||||
</ReportChartTooltip.TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -89,7 +89,10 @@ export function MetricCard({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('group relative p-4', isEditMode && 'card h-auto')}
|
||||
className={cn(
|
||||
'group relative p-4 hover:z-10',
|
||||
isEditMode && 'card h-auto',
|
||||
)}
|
||||
key={serie.id}
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -12,7 +12,7 @@ import CohortTable from './table';
|
||||
export function ReportRetentionChart() {
|
||||
const {
|
||||
report: {
|
||||
events,
|
||||
series,
|
||||
range,
|
||||
projectId,
|
||||
startDate,
|
||||
@@ -22,8 +22,9 @@ export function ReportRetentionChart() {
|
||||
},
|
||||
isLazyLoading,
|
||||
} = useReportChartContext();
|
||||
const firstEvent = (events[0]?.filters[0]?.value ?? []).map(String);
|
||||
const secondEvent = (events[1]?.filters[0]?.value ?? []).map(String);
|
||||
const eventSeries = series.filter((item) => item.type === 'event');
|
||||
const firstEvent = (eventSeries[0]?.filters?.[0]?.value ?? []).map(String);
|
||||
const secondEvent = (eventSeries[1]?.filters?.[0]?.value ?? []).map(String);
|
||||
const isEnabled =
|
||||
firstEvent.length > 0 && secondEvent.length > 0 && !isLazyLoading;
|
||||
const trpc = useTRPC();
|
||||
|
||||
@@ -7,7 +7,7 @@ type ChartRootShortcutProps = Omit<ReportChartProps, 'report'> & {
|
||||
previous?: ReportChartProps['report']['previous'];
|
||||
chartType?: ReportChartProps['report']['chartType'];
|
||||
interval?: ReportChartProps['report']['interval'];
|
||||
events: ReportChartProps['report']['events'];
|
||||
series: ReportChartProps['report']['series'];
|
||||
breakdowns?: ReportChartProps['report']['breakdowns'];
|
||||
lineType?: ReportChartProps['report']['lineType'];
|
||||
};
|
||||
@@ -18,7 +18,7 @@ export const ReportChartShortcut = ({
|
||||
previous = false,
|
||||
chartType = 'linear',
|
||||
interval = 'day',
|
||||
events,
|
||||
series,
|
||||
breakdowns,
|
||||
lineType = 'monotone',
|
||||
options,
|
||||
@@ -33,7 +33,7 @@ export const ReportChartShortcut = ({
|
||||
previous,
|
||||
chartType,
|
||||
interval,
|
||||
events,
|
||||
series,
|
||||
lineType,
|
||||
metric: 'sum',
|
||||
}}
|
||||
|
||||
@@ -11,12 +11,14 @@ import {
|
||||
} from '@openpanel/constants';
|
||||
import type {
|
||||
IChartBreakdown,
|
||||
IChartEvent,
|
||||
IChartEventItem,
|
||||
IChartFormula,
|
||||
IChartLineType,
|
||||
IChartProps,
|
||||
IChartRange,
|
||||
IChartType,
|
||||
IInterval,
|
||||
UnionOmit,
|
||||
zCriteria,
|
||||
} from '@openpanel/validation';
|
||||
import type { z } from 'zod';
|
||||
@@ -39,7 +41,7 @@ const initialState: InitialState = {
|
||||
lineType: 'monotone',
|
||||
interval: 'day',
|
||||
breakdowns: [],
|
||||
events: [],
|
||||
series: [],
|
||||
range: '30d',
|
||||
startDate: null,
|
||||
endDate: null,
|
||||
@@ -86,24 +88,34 @@ export const reportSlice = createSlice({
|
||||
state.dirty = true;
|
||||
state.name = action.payload;
|
||||
},
|
||||
// Events
|
||||
addEvent: (state, action: PayloadAction<Omit<IChartEvent, 'id'>>) => {
|
||||
// Series (Events and Formulas)
|
||||
addSerie: (
|
||||
state,
|
||||
action: PayloadAction<UnionOmit<IChartEventItem, 'id'>>,
|
||||
) => {
|
||||
state.dirty = true;
|
||||
state.events.push({
|
||||
state.series.push({
|
||||
id: shortId(),
|
||||
...action.payload,
|
||||
});
|
||||
},
|
||||
duplicateEvent: (state, action: PayloadAction<Omit<IChartEvent, 'id'>>) => {
|
||||
duplicateEvent: (state, action: PayloadAction<IChartEventItem>) => {
|
||||
state.dirty = true;
|
||||
state.events.push({
|
||||
...action.payload,
|
||||
filters: action.payload.filters.map((filter) => ({
|
||||
...filter,
|
||||
if (action.payload.type === 'event') {
|
||||
state.series.push({
|
||||
...action.payload,
|
||||
filters: action.payload.filters.map((filter) => ({
|
||||
...filter,
|
||||
id: shortId(),
|
||||
})),
|
||||
id: shortId(),
|
||||
})),
|
||||
id: shortId(),
|
||||
});
|
||||
} as IChartEventItem);
|
||||
} else {
|
||||
state.series.push({
|
||||
...action.payload,
|
||||
id: shortId(),
|
||||
} as IChartEventItem);
|
||||
}
|
||||
},
|
||||
removeEvent: (
|
||||
state,
|
||||
@@ -112,13 +124,13 @@ export const reportSlice = createSlice({
|
||||
}>,
|
||||
) => {
|
||||
state.dirty = true;
|
||||
state.events = state.events.filter(
|
||||
(event) => event.id !== action.payload.id,
|
||||
);
|
||||
state.series = state.series.filter((event) => {
|
||||
return event.id !== action.payload.id;
|
||||
});
|
||||
},
|
||||
changeEvent: (state, action: PayloadAction<IChartEvent>) => {
|
||||
changeEvent: (state, action: PayloadAction<IChartEventItem>) => {
|
||||
state.dirty = true;
|
||||
state.events = state.events.map((event) => {
|
||||
state.series = state.series.map((event) => {
|
||||
if (event.id === action.payload.id) {
|
||||
return action.payload;
|
||||
}
|
||||
@@ -265,9 +277,9 @@ export const reportSlice = createSlice({
|
||||
) {
|
||||
state.dirty = true;
|
||||
const { fromIndex, toIndex } = action.payload;
|
||||
const [movedEvent] = state.events.splice(fromIndex, 1);
|
||||
const [movedEvent] = state.series.splice(fromIndex, 1);
|
||||
if (movedEvent) {
|
||||
state.events.splice(toIndex, 0, movedEvent);
|
||||
state.series.splice(toIndex, 0, movedEvent);
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -279,7 +291,7 @@ export const {
|
||||
ready,
|
||||
setReport,
|
||||
setName,
|
||||
addEvent,
|
||||
addSerie,
|
||||
removeEvent,
|
||||
duplicateEvent,
|
||||
changeEvent,
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { Combobox } from '@/components/ui/combobox';
|
||||
import { useAppParams } from '@/hooks/use-app-params';
|
||||
import { useEventProperties } from '@/hooks/use-event-properties';
|
||||
import { useDispatch, useSelector } from '@/redux';
|
||||
import { api } from '@/trpc/client';
|
||||
import { useDispatch } from '@/redux';
|
||||
import { cn } from '@/utils/cn';
|
||||
import { DatabaseIcon } from 'lucide-react';
|
||||
|
||||
@@ -43,6 +42,7 @@ export function EventPropertiesCombobox({
|
||||
changeEvent({
|
||||
...event,
|
||||
property: value,
|
||||
type: 'event',
|
||||
}),
|
||||
);
|
||||
}}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { ColorSquare } from '@/components/color-square';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ComboboxEvents } from '@/components/ui/combobox-events';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { InputEnter } from '@/components/ui/input-enter';
|
||||
import { useAppParams } from '@/hooks/use-app-params';
|
||||
import { useDebounceFn } from '@/hooks/use-debounce-fn';
|
||||
import { useEventNames } from '@/hooks/use-event-names';
|
||||
@@ -23,11 +25,11 @@ import {
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { shortId } from '@openpanel/common';
|
||||
import { alphabetIds } from '@openpanel/constants';
|
||||
import type { IChartEvent } from '@openpanel/validation';
|
||||
import { FilterIcon, HandIcon } from 'lucide-react';
|
||||
import type { IChartEventItem, IChartFormula } from '@openpanel/validation';
|
||||
import { FilterIcon, HandIcon, PlusIcon } from 'lucide-react';
|
||||
import { ReportSegment } from '../ReportSegment';
|
||||
import {
|
||||
addEvent,
|
||||
addSerie,
|
||||
changeEvent,
|
||||
duplicateEvent,
|
||||
removeEvent,
|
||||
@@ -47,7 +49,7 @@ function SortableEvent({
|
||||
isSelectManyEvents,
|
||||
...props
|
||||
}: {
|
||||
event: IChartEvent;
|
||||
event: IChartEventItem;
|
||||
index: number;
|
||||
showSegment: boolean;
|
||||
showAddFilter: boolean;
|
||||
@@ -62,6 +64,8 @@ function SortableEvent({
|
||||
transition,
|
||||
};
|
||||
|
||||
const isEvent = event.type === 'event';
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} {...attributes} {...props}>
|
||||
<div className="flex items-center gap-2 p-2 group">
|
||||
@@ -76,8 +80,8 @@ function SortableEvent({
|
||||
{props.children}
|
||||
</div>
|
||||
|
||||
{/* Segment and Filter buttons */}
|
||||
{(showSegment || showAddFilter) && (
|
||||
{/* Segment and Filter buttons - only for events */}
|
||||
{isEvent && (showSegment || showAddFilter) && (
|
||||
<div className="flex gap-2 p-2 pt-0">
|
||||
{showSegment && (
|
||||
<ReportSegment
|
||||
@@ -130,14 +134,14 @@ function SortableEvent({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
{!isSelectManyEvents && <FiltersList event={event} />}
|
||||
{/* Filters - only for events */}
|
||||
{isEvent && !isSelectManyEvents && <FiltersList event={event} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReportEvents() {
|
||||
const selectedEvents = useSelector((state) => state.report.events);
|
||||
const selectedEvents = useSelector((state) => state.report.series);
|
||||
const chartType = useSelector((state) => state.report.chartType);
|
||||
const dispatch = useDispatch();
|
||||
const { projectId } = useAppParams();
|
||||
@@ -151,7 +155,7 @@ export function ReportEvents() {
|
||||
const isAddEventDisabled =
|
||||
(chartType === 'retention' || chartType === 'conversion') &&
|
||||
selectedEvents.length >= 2;
|
||||
const dispatchChangeEvent = useDebounceFn((event: IChartEvent) => {
|
||||
const dispatchChangeEvent = useDebounceFn((event: IChartEventItem) => {
|
||||
dispatch(changeEvent(event));
|
||||
});
|
||||
const isSelectManyEvents = chartType === 'retention';
|
||||
@@ -174,11 +178,15 @@ export function ReportEvents() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleMore = (event: IChartEvent) => {
|
||||
const handleMore = (event: IChartEventItem) => {
|
||||
const callback: ReportEventMoreProps['onClick'] = (action) => {
|
||||
switch (action) {
|
||||
case 'remove': {
|
||||
return dispatch(removeEvent(event));
|
||||
return dispatch(
|
||||
removeEvent({
|
||||
id: event.id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
case 'duplicate': {
|
||||
return dispatch(duplicateEvent(event));
|
||||
@@ -189,20 +197,31 @@ export function ReportEvents() {
|
||||
return callback;
|
||||
};
|
||||
|
||||
const dispatchChangeFormula = useDebounceFn((formula: IChartFormula) => {
|
||||
dispatch(changeEvent(formula));
|
||||
});
|
||||
|
||||
const showFormula =
|
||||
chartType !== 'conversion' &&
|
||||
chartType !== 'funnel' &&
|
||||
chartType !== 'retention';
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="mb-2 font-medium">Events</h3>
|
||||
<h3 className="mb-2 font-medium">Metrics</h3>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={selectedEvents.map((e) => ({ id: e.id ?? '' }))}
|
||||
items={selectedEvents.map((e) => ({ id: e.id! }))}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
{selectedEvents.map((event, index) => {
|
||||
const isFormula = event.type === 'formula';
|
||||
|
||||
return (
|
||||
<SortableEvent
|
||||
key={event.id}
|
||||
@@ -213,95 +232,151 @@ export function ReportEvents() {
|
||||
isSelectManyEvents={isSelectManyEvents}
|
||||
className="rounded-lg border bg-def-100"
|
||||
>
|
||||
<ComboboxEvents
|
||||
className="flex-1"
|
||||
searchable
|
||||
multiple={isSelectManyEvents as false}
|
||||
value={
|
||||
(isSelectManyEvents
|
||||
? (event.filters[0]?.value ?? [])
|
||||
: event.name) as any
|
||||
}
|
||||
onChange={(value) => {
|
||||
dispatch(
|
||||
changeEvent(
|
||||
Array.isArray(value)
|
||||
? {
|
||||
id: event.id,
|
||||
segment: 'user',
|
||||
filters: [
|
||||
{
|
||||
name: 'name',
|
||||
operator: 'is',
|
||||
value: value,
|
||||
},
|
||||
],
|
||||
name: '*',
|
||||
}
|
||||
: {
|
||||
{isFormula ? (
|
||||
<>
|
||||
<div className="flex-1 flex flex-col gap-2">
|
||||
<InputEnter
|
||||
placeholder="eg: A+B, A/B"
|
||||
value={event.formula}
|
||||
onChangeValue={(value) => {
|
||||
dispatchChangeFormula({
|
||||
...event,
|
||||
formula: value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{showDisplayNameInput && (
|
||||
<Input
|
||||
placeholder={`Formula (${alphabetIds[index]})`}
|
||||
defaultValue={event.displayName}
|
||||
onChange={(e) => {
|
||||
dispatchChangeFormula({
|
||||
...event,
|
||||
name: value,
|
||||
filters: [],
|
||||
},
|
||||
),
|
||||
);
|
||||
}}
|
||||
items={eventNames}
|
||||
placeholder="Select event"
|
||||
/>
|
||||
{showDisplayNameInput && (
|
||||
<Input
|
||||
placeholder={
|
||||
event.name
|
||||
? `${event.name} (${alphabetIds[index]})`
|
||||
: 'Display name'
|
||||
}
|
||||
defaultValue={event.displayName}
|
||||
onChange={(e) => {
|
||||
dispatchChangeEvent({
|
||||
...event,
|
||||
displayName: e.target.value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
displayName: e.target.value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<ReportEventMore onClick={handleMore(event)} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ComboboxEvents
|
||||
className="flex-1"
|
||||
searchable
|
||||
multiple={isSelectManyEvents as false}
|
||||
value={
|
||||
isSelectManyEvents
|
||||
? (event.filters[0]?.value ?? [])
|
||||
: (event.name as any)
|
||||
}
|
||||
onChange={(value) => {
|
||||
dispatch(
|
||||
changeEvent(
|
||||
Array.isArray(value)
|
||||
? {
|
||||
id: event.id,
|
||||
type: 'event',
|
||||
segment: 'user',
|
||||
filters: [
|
||||
{
|
||||
name: 'name',
|
||||
operator: 'is',
|
||||
value: value,
|
||||
},
|
||||
],
|
||||
name: '*',
|
||||
}
|
||||
: {
|
||||
...event,
|
||||
type: 'event',
|
||||
name: value,
|
||||
filters: [],
|
||||
},
|
||||
),
|
||||
);
|
||||
}}
|
||||
items={eventNames}
|
||||
placeholder="Select event"
|
||||
/>
|
||||
{showDisplayNameInput && (
|
||||
<Input
|
||||
placeholder={
|
||||
event.name
|
||||
? `${event.name} (${alphabetIds[index]})`
|
||||
: 'Display name'
|
||||
}
|
||||
defaultValue={event.displayName}
|
||||
onChange={(e) => {
|
||||
dispatchChangeEvent({
|
||||
...event,
|
||||
displayName: e.target.value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<ReportEventMore onClick={handleMore(event)} />
|
||||
</>
|
||||
)}
|
||||
<ReportEventMore onClick={handleMore(event)} />
|
||||
</SortableEvent>
|
||||
);
|
||||
})}
|
||||
|
||||
<ComboboxEvents
|
||||
disabled={isAddEventDisabled}
|
||||
value={''}
|
||||
searchable
|
||||
onChange={(value) => {
|
||||
if (isSelectManyEvents) {
|
||||
dispatch(
|
||||
addEvent({
|
||||
segment: 'user',
|
||||
name: value,
|
||||
filters: [
|
||||
{
|
||||
name: 'name',
|
||||
operator: 'is',
|
||||
value: [value],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
dispatch(
|
||||
addEvent({
|
||||
name: value,
|
||||
segment: 'event',
|
||||
filters: [],
|
||||
}),
|
||||
);
|
||||
}
|
||||
}}
|
||||
placeholder="Select event"
|
||||
items={eventNames}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<ComboboxEvents
|
||||
disabled={isAddEventDisabled}
|
||||
value={''}
|
||||
searchable
|
||||
onChange={(value) => {
|
||||
if (isSelectManyEvents) {
|
||||
dispatch(
|
||||
addSerie({
|
||||
type: 'event',
|
||||
segment: 'user',
|
||||
name: value,
|
||||
filters: [
|
||||
{
|
||||
name: 'name',
|
||||
operator: 'is',
|
||||
value: [value],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
dispatch(
|
||||
addSerie({
|
||||
type: 'event',
|
||||
name: value,
|
||||
segment: 'event',
|
||||
filters: [],
|
||||
}),
|
||||
);
|
||||
}
|
||||
}}
|
||||
placeholder="Select event"
|
||||
items={eventNames}
|
||||
/>
|
||||
{showFormula && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
icon={PlusIcon}
|
||||
onClick={() => {
|
||||
dispatch(
|
||||
addSerie({
|
||||
type: 'formula',
|
||||
formula: '',
|
||||
displayName: '',
|
||||
}),
|
||||
);
|
||||
}}
|
||||
>
|
||||
Add Formula
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import { useDispatch, useSelector } from '@/redux';
|
||||
|
||||
import { InputEnter } from '@/components/ui/input-enter';
|
||||
import { changeFormula } from '../reportSlice';
|
||||
|
||||
export function ReportFormula() {
|
||||
const formula = useSelector((state) => state.report.formula);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="mb-2 font-medium">Formula</h3>
|
||||
<div className="flex flex-col gap-4">
|
||||
<InputEnter
|
||||
placeholder="eg: A/B"
|
||||
value={formula ?? ''}
|
||||
onChangeValue={(value) => {
|
||||
dispatch(changeFormula(value));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
414
apps/start/src/components/report/sidebar/ReportSeries.tsx
Normal file
414
apps/start/src/components/report/sidebar/ReportSeries.tsx
Normal file
@@ -0,0 +1,414 @@
|
||||
import { ColorSquare } from '@/components/color-square';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ComboboxEvents } from '@/components/ui/combobox-events';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { InputEnter } from '@/components/ui/input-enter';
|
||||
import { useAppParams } from '@/hooks/use-app-params';
|
||||
import { useDebounceFn } from '@/hooks/use-debounce-fn';
|
||||
import { useEventNames } from '@/hooks/use-event-names';
|
||||
import { useDispatch, useSelector } from '@/redux';
|
||||
import {
|
||||
DndContext,
|
||||
type DragEndEvent,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
closestCenter,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { shortId } from '@openpanel/common';
|
||||
import { alphabetIds } from '@openpanel/constants';
|
||||
import type {
|
||||
IChartEvent,
|
||||
IChartEventItem,
|
||||
IChartFormula,
|
||||
} from '@openpanel/validation';
|
||||
import { FilterIcon, HandIcon, PiIcon } from 'lucide-react';
|
||||
import { ReportSegment } from '../ReportSegment';
|
||||
import {
|
||||
addSerie,
|
||||
changeEvent,
|
||||
duplicateEvent,
|
||||
removeEvent,
|
||||
reorderEvents,
|
||||
} from '../reportSlice';
|
||||
import { EventPropertiesCombobox } from './EventPropertiesCombobox';
|
||||
import { PropertiesCombobox } from './PropertiesCombobox';
|
||||
import type { ReportEventMoreProps } from './ReportEventMore';
|
||||
import { ReportEventMore } from './ReportEventMore';
|
||||
import { FiltersList } from './filters/FiltersList';
|
||||
|
||||
function SortableSeries({
|
||||
event,
|
||||
index,
|
||||
showSegment,
|
||||
showAddFilter,
|
||||
isSelectManyEvents,
|
||||
...props
|
||||
}: {
|
||||
event: IChartEventItem | IChartEvent;
|
||||
index: number;
|
||||
showSegment: boolean;
|
||||
showAddFilter: boolean;
|
||||
isSelectManyEvents: boolean;
|
||||
} & React.HTMLAttributes<HTMLDivElement>) {
|
||||
const dispatch = useDispatch();
|
||||
const eventId = 'type' in event ? event.id : (event as IChartEvent).id;
|
||||
const { attributes, listeners, setNodeRef, transform, transition } =
|
||||
useSortable({ id: eventId ?? '' });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
// Normalize event to have type field
|
||||
const normalizedEvent: IChartEventItem =
|
||||
'type' in event ? event : { ...event, type: 'event' as const };
|
||||
|
||||
const isFormula = normalizedEvent.type === 'formula';
|
||||
const chartEvent = isFormula
|
||||
? null
|
||||
: (normalizedEvent as IChartEventItem & { type: 'event' });
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} {...attributes} {...props}>
|
||||
<div className="flex items-center gap-2 p-2 group">
|
||||
<button className="cursor-grab active:cursor-grabbing" {...listeners}>
|
||||
<ColorSquare className="relative">
|
||||
<HandIcon className="size-3 opacity-0 scale-50 group-hover:opacity-100 group-hover:scale-100 transition-all absolute inset-1" />
|
||||
<span className="block group-hover:opacity-0 group-hover:scale-0 transition-all">
|
||||
{alphabetIds[index]}
|
||||
</span>
|
||||
</ColorSquare>
|
||||
</button>
|
||||
{props.children}
|
||||
</div>
|
||||
|
||||
{/* Segment and Filter buttons - only for events */}
|
||||
{chartEvent && (showSegment || showAddFilter) && (
|
||||
<div className="flex gap-2 p-2 pt-0">
|
||||
{showSegment && (
|
||||
<ReportSegment
|
||||
value={chartEvent.segment}
|
||||
onChange={(segment) => {
|
||||
dispatch(
|
||||
changeEvent({
|
||||
...chartEvent,
|
||||
segment,
|
||||
}),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{showAddFilter && (
|
||||
<PropertiesCombobox
|
||||
event={chartEvent}
|
||||
onSelect={(action) => {
|
||||
dispatch(
|
||||
changeEvent({
|
||||
...chartEvent,
|
||||
filters: [
|
||||
...chartEvent.filters,
|
||||
{
|
||||
id: shortId(),
|
||||
name: action.value,
|
||||
operator: 'is',
|
||||
value: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
}}
|
||||
>
|
||||
{(setOpen) => (
|
||||
<button
|
||||
onClick={() => setOpen((p) => !p)}
|
||||
type="button"
|
||||
className="flex items-center gap-1 rounded-md border border-border bg-card p-1 px-2 text-sm font-medium leading-none"
|
||||
>
|
||||
<FilterIcon size={12} /> Add filter
|
||||
</button>
|
||||
)}
|
||||
</PropertiesCombobox>
|
||||
)}
|
||||
|
||||
{showSegment && chartEvent.segment.startsWith('property_') && (
|
||||
<EventPropertiesCombobox event={chartEvent} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters - only for events */}
|
||||
{chartEvent && !isSelectManyEvents && <FiltersList event={chartEvent} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReportSeries() {
|
||||
const selectedSeries = useSelector((state) => state.report.series);
|
||||
const chartType = useSelector((state) => state.report.chartType);
|
||||
const dispatch = useDispatch();
|
||||
const { projectId } = useAppParams();
|
||||
const eventNames = useEventNames({
|
||||
projectId,
|
||||
});
|
||||
|
||||
const showSegment = !['retention', 'funnel'].includes(chartType);
|
||||
const showAddFilter = !['retention'].includes(chartType);
|
||||
const showDisplayNameInput = !['retention'].includes(chartType);
|
||||
const isAddEventDisabled =
|
||||
(chartType === 'retention' || chartType === 'conversion') &&
|
||||
selectedSeries.length >= 2;
|
||||
const dispatchChangeEvent = useDebounceFn((event: IChartEventItem) => {
|
||||
dispatch(changeEvent(event));
|
||||
});
|
||||
const isSelectManyEvents = chartType === 'retention';
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
}),
|
||||
);
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
if (over && active.id !== over.id) {
|
||||
const oldIndex = selectedSeries.findIndex((e) => e.id === active.id);
|
||||
const newIndex = selectedSeries.findIndex((e) => e.id === over.id);
|
||||
|
||||
dispatch(reorderEvents({ fromIndex: oldIndex, toIndex: newIndex }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleMore = (event: IChartEventItem | IChartEvent) => {
|
||||
const callback: ReportEventMoreProps['onClick'] = (action) => {
|
||||
switch (action) {
|
||||
case 'remove': {
|
||||
return dispatch(
|
||||
removeEvent({
|
||||
id: 'type' in event ? event.id : (event as IChartEvent).id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
case 'duplicate': {
|
||||
const normalized =
|
||||
'type' in event ? event : { ...event, type: 'event' as const };
|
||||
return dispatch(duplicateEvent(normalized));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return callback;
|
||||
};
|
||||
|
||||
const dispatchChangeFormula = useDebounceFn((formula: IChartFormula) => {
|
||||
dispatch(changeEvent(formula));
|
||||
});
|
||||
|
||||
const showFormula =
|
||||
chartType !== 'conversion' &&
|
||||
chartType !== 'funnel' &&
|
||||
chartType !== 'retention';
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="mb-2 font-medium">Metrics</h3>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={selectedSeries.map((e) => ({
|
||||
id: ('type' in e ? e.id : (e as IChartEvent).id) ?? '',
|
||||
}))}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
{selectedSeries.map((event, index) => {
|
||||
const isFormula = event.type === 'formula';
|
||||
|
||||
return (
|
||||
<SortableSeries
|
||||
key={event.id}
|
||||
event={event}
|
||||
index={index}
|
||||
showSegment={showSegment}
|
||||
showAddFilter={showAddFilter}
|
||||
isSelectManyEvents={isSelectManyEvents}
|
||||
className="rounded-lg border bg-def-100"
|
||||
>
|
||||
{isFormula ? (
|
||||
<>
|
||||
<div className="flex-1 flex flex-col gap-2">
|
||||
<InputEnter
|
||||
placeholder="eg: A+B"
|
||||
value={event.formula}
|
||||
onChangeValue={(value) => {
|
||||
dispatchChangeFormula({
|
||||
...event,
|
||||
formula: value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{showDisplayNameInput && (
|
||||
<Input
|
||||
placeholder={`Name: Formula (${alphabetIds[index]})`}
|
||||
defaultValue={event.displayName}
|
||||
onChange={(e) => {
|
||||
dispatchChangeFormula({
|
||||
...event,
|
||||
displayName: e.target.value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<ReportEventMore onClick={handleMore(event)} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ComboboxEvents
|
||||
className="flex-1"
|
||||
searchable
|
||||
multiple={isSelectManyEvents as false}
|
||||
value={
|
||||
(isSelectManyEvents
|
||||
? ((
|
||||
event as IChartEventItem & {
|
||||
type: 'event';
|
||||
}
|
||||
).filters[0]?.value ?? [])
|
||||
: (
|
||||
event as IChartEventItem & {
|
||||
type: 'event';
|
||||
}
|
||||
).name) as any
|
||||
}
|
||||
onChange={(value) => {
|
||||
dispatch(
|
||||
changeEvent(
|
||||
Array.isArray(value)
|
||||
? {
|
||||
id: event.id,
|
||||
type: 'event',
|
||||
segment: 'user',
|
||||
filters: [
|
||||
{
|
||||
name: 'name',
|
||||
operator: 'is',
|
||||
value: value,
|
||||
},
|
||||
],
|
||||
name: '*',
|
||||
}
|
||||
: {
|
||||
...event,
|
||||
type: 'event',
|
||||
name: value,
|
||||
filters: [],
|
||||
},
|
||||
),
|
||||
);
|
||||
}}
|
||||
items={eventNames}
|
||||
placeholder="Select event"
|
||||
/>
|
||||
{showDisplayNameInput && (
|
||||
<Input
|
||||
placeholder={
|
||||
(event as IChartEventItem & { type: 'event' }).name
|
||||
? `${(event as IChartEventItem & { type: 'event' }).name} (${alphabetIds[index]})`
|
||||
: 'Display name'
|
||||
}
|
||||
defaultValue={
|
||||
(event as IChartEventItem & { type: 'event' })
|
||||
.displayName
|
||||
}
|
||||
onChange={(e) => {
|
||||
dispatchChangeEvent({
|
||||
...(event as IChartEventItem & {
|
||||
type: 'event';
|
||||
}),
|
||||
displayName: e.target.value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<ReportEventMore onClick={handleMore(event)} />
|
||||
</>
|
||||
)}
|
||||
</SortableSeries>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<ComboboxEvents
|
||||
disabled={isAddEventDisabled}
|
||||
value={''}
|
||||
searchable
|
||||
onChange={(value) => {
|
||||
if (isSelectManyEvents) {
|
||||
dispatch(
|
||||
addSerie({
|
||||
type: 'event',
|
||||
segment: 'user',
|
||||
name: value,
|
||||
filters: [
|
||||
{
|
||||
name: 'name',
|
||||
operator: 'is',
|
||||
value: [value],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
dispatch(
|
||||
addSerie({
|
||||
type: 'event',
|
||||
name: value,
|
||||
segment: 'event',
|
||||
filters: [],
|
||||
}),
|
||||
);
|
||||
}
|
||||
}}
|
||||
placeholder="Select event"
|
||||
items={eventNames}
|
||||
/>
|
||||
{showFormula && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
icon={PiIcon}
|
||||
onClick={() => {
|
||||
dispatch(
|
||||
addSerie({
|
||||
type: 'formula',
|
||||
formula: '',
|
||||
displayName: '',
|
||||
}),
|
||||
);
|
||||
}}
|
||||
>
|
||||
Add Formula
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,23 +3,17 @@ import { SheetClose, SheetFooter } from '@/components/ui/sheet';
|
||||
import { useSelector } from '@/redux';
|
||||
|
||||
import { ReportBreakdowns } from './ReportBreakdowns';
|
||||
import { ReportEvents } from './ReportEvents';
|
||||
import { ReportFormula } from './ReportFormula';
|
||||
import { ReportSeries } from './ReportSeries';
|
||||
import { ReportSettings } from './ReportSettings';
|
||||
|
||||
export function ReportSidebar() {
|
||||
const { chartType } = useSelector((state) => state.report);
|
||||
const showFormula =
|
||||
chartType !== 'conversion' &&
|
||||
chartType !== 'funnel' &&
|
||||
chartType !== 'retention';
|
||||
const showBreakdown = chartType !== 'retention';
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-8">
|
||||
<ReportEvents />
|
||||
<ReportSeries />
|
||||
{showBreakdown && <ReportBreakdowns />}
|
||||
{showFormula && <ReportFormula />}
|
||||
<ReportSettings />
|
||||
</div>
|
||||
<SheetFooter>
|
||||
|
||||
@@ -39,14 +39,12 @@ interface PureFilterProps {
|
||||
}
|
||||
|
||||
export function FilterItem({ filter, event }: FilterProps) {
|
||||
// const { range, startDate, endDate, interval } = useSelector(
|
||||
// (state) => state.report,
|
||||
// );
|
||||
const onRemove = ({ id }: IChartEventFilter) => {
|
||||
dispatch(
|
||||
changeEvent({
|
||||
...event,
|
||||
filters: event.filters.filter((item) => item.id !== id),
|
||||
type: 'event',
|
||||
}),
|
||||
);
|
||||
};
|
||||
@@ -58,6 +56,7 @@ export function FilterItem({ filter, event }: FilterProps) {
|
||||
dispatch(
|
||||
changeEvent({
|
||||
...event,
|
||||
type: 'event',
|
||||
filters: event.filters.map((item) => {
|
||||
if (item.id === id) {
|
||||
return {
|
||||
@@ -79,6 +78,7 @@ export function FilterItem({ filter, event }: FilterProps) {
|
||||
dispatch(
|
||||
changeEvent({
|
||||
...event,
|
||||
type: 'event',
|
||||
filters: event.filters.map((item) => {
|
||||
if (item.id === id) {
|
||||
return {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { AnimatePresence } from 'framer-motion';
|
||||
import { RefreshCcwIcon } from 'lucide-react';
|
||||
import { type InputHTMLAttributes, useEffect, useState } from 'react';
|
||||
import { Badge } from './badge';
|
||||
import { Input } from './input';
|
||||
import { Input, type InputProps } from './input';
|
||||
|
||||
export function InputEnter({
|
||||
value,
|
||||
@@ -13,7 +13,7 @@ export function InputEnter({
|
||||
}: {
|
||||
value: string | undefined;
|
||||
onChangeValue: (value: string) => void;
|
||||
} & InputHTMLAttributes<HTMLInputElement>) {
|
||||
} & InputProps) {
|
||||
const [internalValue, setInternalValue] = useState(value ?? '');
|
||||
|
||||
useEffect(() => {
|
||||
@@ -33,7 +33,6 @@ export function InputEnter({
|
||||
onChangeValue(internalValue);
|
||||
}
|
||||
}}
|
||||
size="default"
|
||||
/>
|
||||
<div className="absolute right-2 top-1/2 -translate-y-1/2">
|
||||
<AnimatePresence>
|
||||
|
||||
@@ -1,8 +1,36 @@
|
||||
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
|
||||
import type * as React from 'react';
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export const VirtualScrollArea = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
{
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
>(({ children, className }, ref) => {
|
||||
// The ref MUST point directly to the scrollable element
|
||||
// This element MUST have:
|
||||
// 1. overflow-y-auto (or overflow: auto)
|
||||
// 2. A constrained height (via flex-1 min-h-0 or fixed height)
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('overflow-y-auto w-full', className)}
|
||||
style={{
|
||||
// Ensure height is constrained by flex parent
|
||||
height: '100%',
|
||||
maxHeight: '100%',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
VirtualScrollArea.displayName = 'VirtualScrollArea';
|
||||
|
||||
function ScrollArea({
|
||||
className,
|
||||
children,
|
||||
|
||||
Reference in New Issue
Block a user