feat: report editor

commit bfcf271a64c33a60f61f511cec2198d9c8a9c51a
Author: Carl-Gerhard Lindesvärd <lindesvard@gmail.com>
Date:   Wed Nov 26 12:32:40 2025 +0100

    wip

commit 8cd3b89fa3
Author: Carl-Gerhard Lindesvärd <lindesvard@gmail.com>
Date:   Tue Nov 25 22:33:58 2025 +0100

    funnel

commit 95af86dc44
Author: Carl-Gerhard Lindesvärd <lindesvard@gmail.com>
Date:   Tue Nov 25 22:23:25 2025 +0100

    wip

commit 727a218e6b
Author: Carl-Gerhard Lindesvärd <lindesvard@gmail.com>
Date:   Tue Nov 25 10:18:26 2025 +0100

    conversion wip

commit 958ba535d6
Author: Carl-Gerhard Lindesvärd <lindesvard@gmail.com>
Date:   Tue Nov 25 10:18:20 2025 +0100

    wip

commit 3bbeb927cc
Author: Carl-Gerhard Lindesvärd <lindesvard@gmail.com>
Date:   Tue Nov 25 09:18:48 2025 +0100

    wip

commit d99335e2f4
Author: Carl-Gerhard Lindesvärd <lindesvard@gmail.com>
Date:   Mon Nov 24 18:08:10 2025 +0100

    wip

commit 1fa61b1ae9
Author: Carl-Gerhard Lindesvärd <lindesvard@gmail.com>
Date:   Mon Nov 24 15:50:28 2025 +0100

    ts

commit 548747d826
Author: Carl-Gerhard Lindesvärd <lindesvard@gmail.com>
Date:   Mon Nov 24 13:17:01 2025 +0100

    fix typecheck events -> series

commit 7b18544085
Author: Carl-Gerhard Lindesvärd <lindesvard@gmail.com>
Date:   Mon Nov 24 13:06:46 2025 +0100

    fix report table

commit 57697a5a39
Author: Carl-Gerhard Lindesvärd <lindesvard@gmail.com>
Date:   Sat Nov 22 00:05:13 2025 +0100

    wip

commit 06fb6c4f3c
Author: Carl-Gerhard Lindesvärd <lindesvard@gmail.com>
Date:   Fri Nov 21 11:21:17 2025 +0100

    wip

commit dd71fd4e11
Author: Carl-Gerhard Lindesvärd <lindesvard@gmail.com>
Date:   Thu Nov 20 13:56:58 2025 +0100

    formulas
This commit is contained in:
Carl-Gerhard Lindesvärd
2025-11-26 12:33:41 +01:00
parent 828c8c4f91
commit b421474616
70 changed files with 6867 additions and 1918 deletions

View File

@@ -17,7 +17,7 @@
"editor.defaultFormatter": "biomejs.biome" "editor.defaultFormatter": "biomejs.biome"
}, },
"[json]": { "[json]": {
"editor.defaultFormatter": "biomejs.biome" "editor.defaultFormatter": "vscode.json-language-features"
}, },
"editor.formatOnSave": true, "editor.formatOnSave": true,
"tailwindCSS.experimental.classRegex": [ "tailwindCSS.experimental.classRegex": [

View File

@@ -12,8 +12,8 @@ import {
getEventsCountCached, getEventsCountCached,
getSettingsForProject, getSettingsForProject,
} from '@openpanel/db'; } from '@openpanel/db';
import { getChart } from '@openpanel/trpc/src/routers/chart.helpers'; import { ChartEngine } from '@openpanel/db';
import { zChartEvent, zChartInput } from '@openpanel/validation'; import { zChartEvent, zChartInputBase } from '@openpanel/validation';
import { omit } from 'ramda'; import { omit } from 'ramda';
async function getProjectId( async function getProjectId(
@@ -139,7 +139,7 @@ export async function events(
}); });
} }
const chartSchemeFull = zChartInput const chartSchemeFull = zChartInputBase
.pick({ .pick({
breakdowns: true, breakdowns: true,
interval: true, interval: true,
@@ -151,14 +151,27 @@ const chartSchemeFull = zChartInput
.extend({ .extend({
project_id: z.string().optional(), project_id: z.string().optional(),
projectId: z.string().optional(), projectId: z.string().optional(),
events: z.array( series: z
.array(
z.object({ z.object({
name: z.string(), name: z.string(),
filters: zChartEvent.shape.filters.optional(), filters: zChartEvent.shape.filters.optional(),
segment: zChartEvent.shape.segment.optional(), segment: zChartEvent.shape.segment.optional(),
property: zChartEvent.shape.property.optional(), property: zChartEvent.shape.property.optional(),
}), }),
), )
.optional(),
// Backward compatibility - events will be migrated to series via preprocessing
events: z
.array(
z.object({
name: z.string(),
filters: zChartEvent.shape.filters.optional(),
segment: zChartEvent.shape.segment.optional(),
property: zChartEvent.shape.property.optional(),
}),
)
.optional(),
}); });
export async function charts( export async function charts(
@@ -179,9 +192,17 @@ export async function charts(
const projectId = await getProjectId(request, reply); const projectId = await getProjectId(request, reply);
const { timezone } = await getSettingsForProject(projectId); const { timezone } = await getSettingsForProject(projectId);
const { events, ...rest } = query.data; const { events, series, ...rest } = query.data;
return getChart({ // Use series if available, otherwise fall back to events (backward compat)
const eventSeries = (series ?? events ?? []).map((event: any) => ({
...event,
type: event.type ?? 'event',
segment: event.segment ?? 'event',
filters: event.filters ?? [],
}));
return ChartEngine.execute({
...rest, ...rest,
startDate: rest.startDate startDate: rest.startDate
? DateTime.fromISO(rest.startDate) ? DateTime.fromISO(rest.startDate)
@@ -194,11 +215,7 @@ export async function charts(
.toFormat('yyyy-MM-dd HH:mm:ss') .toFormat('yyyy-MM-dd HH:mm:ss')
: undefined, : undefined,
projectId, projectId,
events: events.map((event) => ({ series: eventSeries,
...event,
segment: event.segment ?? 'event',
filters: event.filters ?? [],
})),
chartType: 'linear', chartType: 'linear',
metric: 'sum', metric: 'sum',
}); });

View File

@@ -7,8 +7,8 @@ import {
ch, ch,
clix, clix,
} from '@openpanel/db'; } from '@openpanel/db';
import { ChartEngine } from '@openpanel/db';
import { getCache } from '@openpanel/redis'; import { getCache } from '@openpanel/redis';
import { getChart } from '@openpanel/trpc/src/routers/chart.helpers';
import { zChartInputAI } from '@openpanel/validation'; import { zChartInputAI } from '@openpanel/validation';
import { tool } from 'ai'; import { tool } from 'ai';
import { z } from 'zod'; import { z } from 'zod';

View File

@@ -103,7 +103,6 @@
"lodash.throttle": "^4.1.1", "lodash.throttle": "^4.1.1",
"lottie-react": "^2.4.0", "lottie-react": "^2.4.0",
"lucide-react": "^0.476.0", "lucide-react": "^0.476.0",
"mathjs": "^12.3.2",
"mitt": "^3.0.1", "mitt": "^3.0.1",
"nuqs": "^2.5.2", "nuqs": "^2.5.2",
"prisma-error-enum": "^0.1.3", "prisma-error-enum": "^0.1.3",

View File

@@ -153,7 +153,7 @@ export function OverviewMetricCard({
width={width} width={width}
height={height / 4} height={height / 4}
data={data} data={data}
style={{ marginTop: (height / 4) * 3 }} style={{ marginTop: (height / 4) * 3, background: 'transparent' }}
onMouseMove={(event) => { onMouseMove={(event) => {
setCurrentIndex(event.activeTooltipIndex ?? null); setCurrentIndex(event.activeTooltipIndex ?? null);
}} }}

View File

@@ -45,8 +45,9 @@ export default function OverviewTopDevices({
projectId, projectId,
startDate, startDate,
endDate, endDate,
events: [ series: [
{ {
type: 'event',
segment: 'user', segment: 'user',
filters, filters,
id: 'A', id: 'A',
@@ -81,8 +82,9 @@ export default function OverviewTopDevices({
projectId, projectId,
startDate, startDate,
endDate, endDate,
events: [ series: [
{ {
type: 'event',
segment: 'user', segment: 'user',
filters, filters,
id: 'A', id: 'A',
@@ -120,8 +122,9 @@ export default function OverviewTopDevices({
projectId, projectId,
startDate, startDate,
endDate, endDate,
events: [ series: [
{ {
type: 'event',
segment: 'user', segment: 'user',
filters, filters,
id: 'A', id: 'A',
@@ -160,8 +163,9 @@ export default function OverviewTopDevices({
projectId, projectId,
startDate, startDate,
endDate, endDate,
events: [ series: [
{ {
type: 'event',
segment: 'user', segment: 'user',
filters, filters,
id: 'A', id: 'A',
@@ -199,8 +203,9 @@ export default function OverviewTopDevices({
projectId, projectId,
startDate, startDate,
endDate, endDate,
events: [ series: [
{ {
type: 'event',
segment: 'user', segment: 'user',
filters, filters,
id: 'A', id: 'A',
@@ -239,8 +244,9 @@ export default function OverviewTopDevices({
projectId, projectId,
startDate, startDate,
endDate, endDate,
events: [ series: [
{ {
type: 'event',
segment: 'user', segment: 'user',
filters, filters,
id: 'A', id: 'A',
@@ -278,8 +284,9 @@ export default function OverviewTopDevices({
projectId, projectId,
startDate, startDate,
endDate, endDate,
events: [ series: [
{ {
type: 'event',
segment: 'user', segment: 'user',
filters, filters,
id: 'A', id: 'A',

View File

@@ -7,6 +7,7 @@ import type { IChartType } from '@openpanel/validation';
import { useTRPC } from '@/integrations/trpc/react'; import { useTRPC } from '@/integrations/trpc/react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { SerieIcon } from '../report-chart/common/serie-icon';
import { Widget, WidgetBody } from '../widget'; import { Widget, WidgetBody } from '../widget';
import { OverviewChartToggle } from './overview-chart-toggle'; import { OverviewChartToggle } from './overview-chart-toggle';
import { WidgetButtons, WidgetFooter, WidgetHead } from './overview-widget'; import { WidgetButtons, WidgetFooter, WidgetHead } from './overview-widget';
@@ -37,8 +38,9 @@ export default function OverviewTopEvents({
projectId, projectId,
startDate, startDate,
endDate, endDate,
events: [ series: [
{ {
type: 'event',
segment: 'event', segment: 'event',
filters: [ filters: [
...filters, ...filters,
@@ -78,8 +80,9 @@ export default function OverviewTopEvents({
projectId, projectId,
startDate, startDate,
endDate, endDate,
events: [ series: [
{ {
type: 'event',
segment: 'event', segment: 'event',
filters: [...filters], filters: [...filters],
id: 'A', id: 'A',
@@ -112,8 +115,9 @@ export default function OverviewTopEvents({
projectId, projectId,
startDate, startDate,
endDate, endDate,
events: [ series: [
{ {
type: 'event',
segment: 'event', segment: 'event',
filters: [ filters: [
...filters, ...filters,
@@ -168,7 +172,13 @@ export default function OverviewTopEvents({
</WidgetHead> </WidgetHead>
<WidgetBody className="p-3"> <WidgetBody className="p-3">
<ReportChart <ReportChart
options={{ hideID: true, columns: ['Event', 'Count'] }} options={{
hideID: true,
columns: ['Event'],
renderSerieName(names) {
return names[1];
},
}}
report={{ report={{
...widget.chart.report, ...widget.chart.report,
previous: false, previous: false,

View File

@@ -7,6 +7,7 @@ import type { IChartType } from '@openpanel/validation';
import { useNumber } from '@/hooks/use-numer-formatter'; import { useNumber } from '@/hooks/use-numer-formatter';
import { useTRPC } from '@/integrations/trpc/react'; import { useTRPC } from '@/integrations/trpc/react';
import { pushModal } from '@/modals'; import { pushModal } from '@/modals';
import { countries } from '@/translations/countries';
import { NOT_SET_VALUE } from '@openpanel/constants'; import { NOT_SET_VALUE } from '@openpanel/constants';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { ChevronRightIcon } from 'lucide-react'; import { ChevronRightIcon } from 'lucide-react';
@@ -108,13 +109,19 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
> >
{item.prefix && ( {item.prefix && (
<span className="mr-1 row inline-flex items-center gap-1"> <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> <span>
<ChevronRightIcon className="size-3" /> <ChevronRightIcon className="size-3" />
</span> </span>
</span> </span>
)} )}
{item.name || 'Not set'} {(countries[item.name as keyof typeof countries] ??
item.name) ||
'Not set'}
</button> </button>
</div> </div>
); );
@@ -146,8 +153,9 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
projectId, projectId,
startDate, startDate,
endDate, endDate,
events: [ series: [
{ {
type: 'event',
segment: 'event', segment: 'event',
filters, filters,
id: 'A', id: 'A',

View File

@@ -15,8 +15,9 @@ export const ProfileCharts = memo(
const pageViewsChart: IChartProps = { const pageViewsChart: IChartProps = {
projectId, projectId,
chartType: 'linear', chartType: 'linear',
events: [ series: [
{ {
type: 'event',
segment: 'event', segment: 'event',
filters: [ filters: [
{ {
@@ -48,8 +49,9 @@ export const ProfileCharts = memo(
const eventsChart: IChartProps = { const eventsChart: IChartProps = {
projectId, projectId,
chartType: 'linear', chartType: 'linear',
events: [ series: [
{ {
type: 'event',
segment: 'event', segment: 'event',
filters: [ filters: [
{ {

View File

@@ -7,6 +7,7 @@ import { cn } from '@/utils/cn';
import { getChartColor } from '@/utils/theme'; import { getChartColor } from '@/utils/theme';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { isSameDay, isSameHour, isSameMonth, isSameWeek } from 'date-fns'; import { isSameDay, isSameHour, isSameMonth, isSameWeek } from 'date-fns';
import { BookmarkIcon, UsersIcon } from 'lucide-react';
import { last } from 'ramda'; import { last } from 'ramda';
import { useCallback } from 'react'; import { useCallback } from 'react';
import { import {
@@ -25,6 +26,10 @@ import {
import { useDashedStroke } from '@/hooks/use-dashed-stroke'; import { useDashedStroke } from '@/hooks/use-dashed-stroke';
import { useXAxisProps, useYAxisProps } from '../common/axis'; import { useXAxisProps, useYAxisProps } from '../common/axis';
import {
ChartClickMenu,
type ChartClickMenuItem,
} from '../common/chart-click-menu';
import { ReportChartTooltip } from '../common/report-chart-tooltip'; import { ReportChartTooltip } from '../common/report-chart-tooltip';
import { ReportTable } from '../common/report-table'; import { ReportTable } from '../common/report-table';
import { SerieIcon } from '../common/serie-icon'; import { SerieIcon } from '../common/serie-icon';
@@ -45,6 +50,8 @@ export function Chart({ data }: Props) {
endDate, endDate,
range, range,
lineType, lineType,
series: reportSeries,
breakdowns,
}, },
isEditMode, isEditMode,
options: { hideXAxis, hideYAxis }, options: { hideXAxis, hideYAxis },
@@ -126,16 +133,66 @@ export function Chart({ data }: Props) {
interval, interval,
}); });
const handleChartClick = useCallback((e: any) => { const getMenuItems = useCallback(
if (e?.activePayload?.[0]) { (e: any, clickedData: any): ChartClickMenuItem[] => {
const clickedData = e.activePayload[0].payload; const items: ChartClickMenuItem[] = [];
if (clickedData.date) {
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', { pushModal('AddReference', {
datetime: new Date(clickedData.date).toISOString(), datetime: new Date(clickedData.date).toISOString(),
}); });
} },
} });
}, []);
return items;
},
[
projectId,
data,
reportSeries,
breakdowns,
interval,
startDate,
endDate,
range,
previous,
],
);
const { getStrokeDasharray, calcStrokeDasharray, handleAnimationEnd } = const { getStrokeDasharray, calcStrokeDasharray, handleAnimationEnd } =
useDashedStroke({ useDashedStroke({
@@ -144,9 +201,10 @@ export function Chart({ data }: Props) {
return ( return (
<ReportChartTooltip.TooltipProvider references={references.data}> <ReportChartTooltip.TooltipProvider references={references.data}>
<ChartClickMenu getMenuItems={getMenuItems}>
<div className={cn('h-full w-full', isEditMode && 'card p-4')}> <div className={cn('h-full w-full', isEditMode && 'card p-4')}>
<ResponsiveContainer> <ResponsiveContainer>
<ComposedChart data={rechartData} onClick={handleChartClick}> <ComposedChart data={rechartData}>
<Customized component={calcStrokeDasharray} /> <Customized component={calcStrokeDasharray} />
<Line <Line
dataKey="calcStrokeDasharray" dataKey="calcStrokeDasharray"
@@ -191,7 +249,11 @@ export function Chart({ data }: Props) {
y2="1" y2="1"
> >
<stop offset="0%" stopColor={color} stopOpacity={0.8} /> <stop offset="0%" stopColor={color} stopOpacity={0.8} />
<stop offset={'100%'} stopColor={color} stopOpacity={0.1} /> <stop
offset={'100%'}
stopColor={color}
stopOpacity={0.1}
/>
</linearGradient> </linearGradient>
</defs> </defs>
); );
@@ -211,6 +273,8 @@ export function Chart({ data }: Props) {
: undefined : undefined
} }
fill={`url(#color${color})`} fill={`url(#color${color})`}
stroke={color}
strokeWidth={2}
isAnimationActive={false} isAnimationActive={false}
fillOpacity={0.7} fillOpacity={0.7}
/> />
@@ -244,6 +308,7 @@ export function Chart({ data }: Props) {
setVisibleSeries={setVisibleSeries} setVisibleSeries={setVisibleSeries}
/> />
)} )}
</ChartClickMenu>
</ReportChartTooltip.TooltipProvider> </ReportChartTooltip.TooltipProvider>
); );
} }

View File

@@ -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';

View File

@@ -17,10 +17,10 @@ export function ReportChartEmpty({
}) { }) {
const { const {
isEditMode, isEditMode,
report: { events }, report: { series },
} = useReportChartContext(); } = useReportChartContext();
if (events.length === 0) { if (!series || series.length === 0) {
return ( return (
<div className="card p-4 center-center h-full w-full flex-col relative"> <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"> <div className="row gap-2 items-end absolute top-4 left-4">

View File

@@ -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>
);
}

View File

@@ -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

View File

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

View File

@@ -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>
);
}

View File

@@ -13,7 +13,7 @@ import { Summary } from './summary';
export function ReportConversionChart() { export function ReportConversionChart() {
const { isLazyLoading, report } = useReportChartContext(); const { isLazyLoading, report } = useReportChartContext();
const trpc = useTRPC(); const trpc = useTRPC();
console.log(report.limit);
const res = useQuery( const res = useQuery(
trpc.chart.conversion.queryOptions(report, { trpc.chart.conversion.queryOptions(report, {
placeholderData: keepPreviousData, placeholderData: keepPreviousData,

View File

@@ -144,7 +144,9 @@ export function Summary({ data }: Props) {
title="Flow" title="Flow"
value={ value={
<div className="row flex-wrap gap-1"> <div className="row flex-wrap gap-1">
{report.events.map((event, index) => { {report.series
.filter((item) => item.type === 'event')
.map((event, index) => {
return ( return (
<div key={event.id} className="row items-center gap-2"> <div key={event.id} className="row items-center gap-2">
{index !== 0 && <ChevronRightIcon className="size-3" />} {index !== 0 && <ChevronRightIcon className="size-3" />}

View File

@@ -1,7 +1,9 @@
import { ColorSquare } from '@/components/color-square'; import { ColorSquare } from '@/components/color-square';
import { Button } from '@/components/ui/button';
import { pushModal } from '@/modals';
import type { RouterOutputs } from '@/trpc/client'; import type { RouterOutputs } from '@/trpc/client';
import { cn } from '@/utils/cn'; import { cn } from '@/utils/cn';
import { ChevronRightIcon, InfoIcon } from 'lucide-react'; import { ChevronRightIcon, InfoIcon, UsersIcon } from 'lucide-react';
import { alphabetIds } from '@openpanel/constants'; import { alphabetIds } from '@openpanel/constants';
@@ -23,6 +25,7 @@ import {
} from 'recharts'; } from 'recharts';
import { useXAxisProps, useYAxisProps } from '../common/axis'; import { useXAxisProps, useYAxisProps } from '../common/axis';
import { PreviousDiffIndicatorPure } from '../common/previous-diff-indicator'; import { PreviousDiffIndicatorPure } from '../common/previous-diff-indicator';
import { useReportChartContext } from '../context';
type Props = { type Props = {
data: { data: {
@@ -113,11 +116,50 @@ function ChartName({
export function Tables({ export function Tables({
data: { data: {
current: { steps, mostDropoffsStep, lastStep, breakdowns }, current: { steps, mostDropoffsStep, lastStep, breakdowns },
previous, previous: previousData,
}, },
}: Props) { }: Props) {
const number = useNumber(); const number = useNumber();
const hasHeader = breakdowns.length > 0; 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 ( return (
<div className={cn('col @container divide-y divide-border card')}> <div className={cn('col @container divide-y divide-border card')}>
{hasHeader && <ChartName breakdowns={breakdowns} className="p-4 py-3" />} {hasHeader && <ChartName breakdowns={breakdowns} className="p-4 py-3" />}
@@ -128,11 +170,11 @@ export function Tables({
label="Conversion" label="Conversion"
value={number.formatWithUnit(lastStep?.percent / 100, '%')} value={number.formatWithUnit(lastStep?.percent / 100, '%')}
enhancer={ enhancer={
previous && ( previousData && (
<PreviousDiffIndicatorPure <PreviousDiffIndicatorPure
{...getPreviousMetric( {...getPreviousMetric(
lastStep?.percent, lastStep?.percent,
previous.lastStep?.percent, previousData.lastStep?.percent,
)} )}
/> />
) )
@@ -143,11 +185,11 @@ export function Tables({
label="Completed" label="Completed"
value={number.format(lastStep?.count)} value={number.format(lastStep?.count)}
enhancer={ enhancer={
previous && ( previousData && (
<PreviousDiffIndicatorPure <PreviousDiffIndicatorPure
{...getPreviousMetric( {...getPreviousMetric(
lastStep?.count, lastStep?.count,
previous.lastStep?.count, previousData.lastStep?.count,
)} )}
/> />
) )
@@ -238,6 +280,28 @@ export function Tables({
className: 'text-right font-mono font-semibold', className: 'text-right font-mono font-semibold',
width: '90px', 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> </div>
@@ -299,6 +363,7 @@ export function Chart({ data }: { data: RouterOutputs['chart']['funnel'] }) {
const rechartData = useRechartData(data); const rechartData = useRechartData(data);
const xAxisProps = useXAxisProps(); const xAxisProps = useXAxisProps();
const yAxisProps = useYAxisProps(); const yAxisProps = useYAxisProps();
const hasBreakdowns = data.current.length > 1;
return ( return (
<TooltipProvider data={data.current}> <TooltipProvider data={data.current}>
@@ -327,6 +392,23 @@ export function Chart({ data }: { data: RouterOutputs['chart']['funnel'] }) {
} }
/> />
<YAxis {...yAxisProps} /> <YAxis {...yAxisProps} />
{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 <Bar
data={rechartData} data={rechartData}
dataKey="step:percent:0" dataKey="step:percent:0"
@@ -340,6 +422,7 @@ export function Chart({ data }: { data: RouterOutputs['chart']['funnel'] }) {
/> />
))} ))}
</Bar> </Bar>
)}
<Tooltip /> <Tooltip />
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
@@ -348,8 +431,6 @@ export function Chart({ data }: { data: RouterOutputs['chart']['funnel'] }) {
); );
} }
type Hej = RouterOutputs['chart']['funnel']['current'];
const { Tooltip, TooltipProvider } = createChartTooltip< const { Tooltip, TooltipProvider } = createChartTooltip<
RechartData, RechartData,
{ {
@@ -371,7 +452,7 @@ const { Tooltip, TooltipProvider } = createChartTooltip<
<div className="flex justify-between gap-8 text-muted-foreground"> <div className="flex justify-between gap-8 text-muted-foreground">
<div>{data.name}</div> <div>{data.name}</div>
</div> </div>
{variants.map((key) => { {variants.map((key, breakdownIndex) => {
const variant = data[key]; const variant = data[key];
const prevVariant = data[`prev_${key}`]; const prevVariant = data[`prev_${key}`];
if (!variant?.step) { if (!variant?.step) {
@@ -381,7 +462,11 @@ const { Tooltip, TooltipProvider } = createChartTooltip<
<div className="row gap-2" key={key}> <div className="row gap-2" key={key}>
<div <div
className="w-[3px] rounded-full" 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="col flex-1 gap-1">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">

View File

@@ -14,7 +14,7 @@ import { Chart, Summary, Tables } from './chart';
export function ReportFunnelChart() { export function ReportFunnelChart() {
const { const {
report: { report: {
events, series,
range, range,
projectId, projectId,
funnelWindow, funnelWindow,
@@ -28,7 +28,7 @@ export function ReportFunnelChart() {
} = useReportChartContext(); } = useReportChartContext();
const input: IChartInput = { const input: IChartInput = {
events, series,
range, range,
projectId, projectId,
interval: 'day', interval: 'day',
@@ -40,11 +40,12 @@ export function ReportFunnelChart() {
metric: 'sum', metric: 'sum',
startDate, startDate,
endDate, endDate,
limit: 20,
}; };
const trpc = useTRPC(); const trpc = useTRPC();
const res = useQuery( const res = useQuery(
trpc.chart.funnel.queryOptions(input, { trpc.chart.funnel.queryOptions(input, {
enabled: !isLazyLoading && input.events.length > 0, enabled: !isLazyLoading && input.series.length > 0,
}), }),
); );

View File

@@ -7,6 +7,7 @@ import type { IChartData } from '@/trpc/client';
import { cn } from '@/utils/cn'; import { cn } from '@/utils/cn';
import { getChartColor } from '@/utils/theme'; import { getChartColor } from '@/utils/theme';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { BookmarkIcon, UsersIcon } from 'lucide-react';
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import { import {
Bar, Bar,
@@ -20,6 +21,10 @@ import {
} from 'recharts'; } from 'recharts';
import { useXAxisProps, useYAxisProps } from '../common/axis'; import { useXAxisProps, useYAxisProps } from '../common/axis';
import {
ChartClickMenu,
type ChartClickMenuItem,
} from '../common/chart-click-menu';
import { ReportChartTooltip } from '../common/report-chart-tooltip'; import { ReportChartTooltip } from '../common/report-chart-tooltip';
import { ReportTable } from '../common/report-table'; import { ReportTable } from '../common/report-table';
import { useReportChartContext } from '../context'; import { useReportChartContext } from '../context';
@@ -47,7 +52,16 @@ function BarHover({ x, y, width, height, top, left, right, bottom }: any) {
export function Chart({ data }: Props) { export function Chart({ data }: Props) {
const { const {
isEditMode, isEditMode,
report: { previous, interval, projectId, startDate, endDate, range }, report: {
previous,
interval,
projectId,
startDate,
endDate,
range,
series: reportSeries,
breakdowns,
},
options: { hideXAxis, hideYAxis }, options: { hideXAxis, hideYAxis },
} = useReportChartContext(); } = useReportChartContext();
const trpc = useTRPC(); const trpc = useTRPC();
@@ -74,22 +88,73 @@ export function Chart({ data }: Props) {
interval, interval,
}); });
const handleChartClick = useCallback((e: any) => { const getMenuItems = useCallback(
if (e?.activePayload?.[0]) { (e: any, clickedData: any): ChartClickMenuItem[] => {
const clickedData = e.activePayload[0].payload; const items: ChartClickMenuItem[] = [];
if (clickedData.date) {
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', { pushModal('AddReference', {
datetime: new Date(clickedData.date).toISOString(), datetime: new Date(clickedData.date).toISOString(),
}); });
} },
} });
}, []);
return items;
},
[
projectId,
data,
reportSeries,
breakdowns,
interval,
startDate,
endDate,
range,
previous,
],
);
return ( return (
<ReportChartTooltip.TooltipProvider references={references.data}> <ReportChartTooltip.TooltipProvider references={references.data}>
<ChartClickMenu getMenuItems={getMenuItems}>
<div className={cn('h-full w-full', isEditMode && 'card p-4')}> <div className={cn('h-full w-full', isEditMode && 'card p-4')}>
<ResponsiveContainer> <ResponsiveContainer>
<BarChart data={rechartData} onClick={handleChartClick}> <BarChart data={rechartData}>
<CartesianGrid <CartesianGrid
strokeDasharray="3 3" strokeDasharray="3 3"
vertical={false} vertical={false}
@@ -152,6 +217,7 @@ export function Chart({ data }: Props) {
setVisibleSeries={setVisibleSeries} setVisibleSeries={setVisibleSeries}
/> />
)} )}
</ChartClickMenu>
</ReportChartTooltip.TooltipProvider> </ReportChartTooltip.TooltipProvider>
); );
} }

View File

@@ -7,6 +7,7 @@ import { cn } from '@/utils/cn';
import { getChartColor } from '@/utils/theme'; import { getChartColor } from '@/utils/theme';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { isSameDay, isSameHour, isSameMonth, isSameWeek } from 'date-fns'; import { isSameDay, isSameHour, isSameMonth, isSameWeek } from 'date-fns';
import { BookmarkIcon, UsersIcon } from 'lucide-react';
import { last } from 'ramda'; import { last } from 'ramda';
import { useCallback } from 'react'; import { useCallback } from 'react';
import { import {
@@ -24,6 +25,10 @@ import {
import { useDashedStroke } from '@/hooks/use-dashed-stroke'; import { useDashedStroke } from '@/hooks/use-dashed-stroke';
import { useXAxisProps, useYAxisProps } from '../common/axis'; import { useXAxisProps, useYAxisProps } from '../common/axis';
import {
ChartClickMenu,
type ChartClickMenuItem,
} from '../common/chart-click-menu';
import { ReportChartTooltip } from '../common/report-chart-tooltip'; import { ReportChartTooltip } from '../common/report-chart-tooltip';
import { ReportTable } from '../common/report-table'; import { ReportTable } from '../common/report-table';
import { SerieIcon } from '../common/serie-icon'; import { SerieIcon } from '../common/serie-icon';
@@ -44,6 +49,8 @@ export function Chart({ data }: Props) {
endDate, endDate,
range, range,
lineType, lineType,
series: reportSeries,
breakdowns,
}, },
isEditMode, isEditMode,
options: { hideXAxis, hideYAxis, maxDomain }, options: { hideXAxis, hideYAxis, maxDomain },
@@ -128,22 +135,84 @@ export function Chart({ data }: Props) {
hide: hideYAxis, hide: hideYAxis,
}); });
const handleChartClick = useCallback((e: any) => { const getMenuItems = useCallback(
if (e?.activePayload?.[0]) { (e: any, clickedData: any): ChartClickMenuItem[] => {
const clickedData = e.activePayload[0].payload; const items: ChartClickMenuItem[] = [];
if (clickedData.date) {
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', { pushModal('AddReference', {
datetime: new Date(clickedData.date).toISOString(), datetime: new Date(clickedData.date).toISOString(),
}); });
} },
} });
}, []);
return items;
},
[
projectId,
data,
reportSeries,
breakdowns,
interval,
startDate,
endDate,
range,
previous,
],
);
return ( return (
<ReportChartTooltip.TooltipProvider references={references.data}> <ReportChartTooltip.TooltipProvider references={references.data}>
<ChartClickMenu getMenuItems={getMenuItems}>
<div className={cn('h-full w-full', isEditMode && 'card p-4')}> <div className={cn('h-full w-full', isEditMode && 'card p-4')}>
<ResponsiveContainer> <ResponsiveContainer>
<ComposedChart data={rechartData} onClick={handleChartClick}> <ComposedChart data={rechartData}>
<Customized component={calcStrokeDasharray} /> <Customized component={calcStrokeDasharray} />
<Line <Line
dataKey="calcStrokeDasharray" dataKey="calcStrokeDasharray"
@@ -179,60 +248,6 @@ export function Chart({ data }: Props) {
<XAxis {...xAxisProps} /> <XAxis {...xAxisProps} />
{series.length > 1 && <Legend content={<CustomLegend />} />} {series.length > 1 && <Legend content={<CustomLegend />} />}
<Tooltip content={<ReportChartTooltip.Tooltip />} /> <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>
<Line
dot={isAreaStyle && 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}
/>
{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> <defs>
<filter <filter
@@ -274,7 +289,9 @@ export function Chart({ data }: Props) {
// Use for legend // Use for legend
fill={color} fill={color}
filter={ filter={
series.length === 1 ? 'url(#rainbow-line-glow)' : undefined series.length === 1
? 'url(#rainbow-line-glow)'
: undefined
} }
/> />
); );
@@ -310,6 +327,7 @@ export function Chart({ data }: Props) {
setVisibleSeries={setVisibleSeries} setVisibleSeries={setVisibleSeries}
/> />
)} )}
</ChartClickMenu>
</ReportChartTooltip.TooltipProvider> </ReportChartTooltip.TooltipProvider>
); );
} }

View File

@@ -89,7 +89,10 @@ export function MetricCard({
return ( return (
<div <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} key={serie.id}
> >
<div <div

View File

@@ -12,7 +12,7 @@ import CohortTable from './table';
export function ReportRetentionChart() { export function ReportRetentionChart() {
const { const {
report: { report: {
events, series,
range, range,
projectId, projectId,
startDate, startDate,
@@ -22,8 +22,9 @@ export function ReportRetentionChart() {
}, },
isLazyLoading, isLazyLoading,
} = useReportChartContext(); } = useReportChartContext();
const firstEvent = (events[0]?.filters[0]?.value ?? []).map(String); const eventSeries = series.filter((item) => item.type === 'event');
const secondEvent = (events[1]?.filters[0]?.value ?? []).map(String); const firstEvent = (eventSeries[0]?.filters?.[0]?.value ?? []).map(String);
const secondEvent = (eventSeries[1]?.filters?.[0]?.value ?? []).map(String);
const isEnabled = const isEnabled =
firstEvent.length > 0 && secondEvent.length > 0 && !isLazyLoading; firstEvent.length > 0 && secondEvent.length > 0 && !isLazyLoading;
const trpc = useTRPC(); const trpc = useTRPC();

View File

@@ -7,7 +7,7 @@ type ChartRootShortcutProps = Omit<ReportChartProps, 'report'> & {
previous?: ReportChartProps['report']['previous']; previous?: ReportChartProps['report']['previous'];
chartType?: ReportChartProps['report']['chartType']; chartType?: ReportChartProps['report']['chartType'];
interval?: ReportChartProps['report']['interval']; interval?: ReportChartProps['report']['interval'];
events: ReportChartProps['report']['events']; series: ReportChartProps['report']['series'];
breakdowns?: ReportChartProps['report']['breakdowns']; breakdowns?: ReportChartProps['report']['breakdowns'];
lineType?: ReportChartProps['report']['lineType']; lineType?: ReportChartProps['report']['lineType'];
}; };
@@ -18,7 +18,7 @@ export const ReportChartShortcut = ({
previous = false, previous = false,
chartType = 'linear', chartType = 'linear',
interval = 'day', interval = 'day',
events, series,
breakdowns, breakdowns,
lineType = 'monotone', lineType = 'monotone',
options, options,
@@ -33,7 +33,7 @@ export const ReportChartShortcut = ({
previous, previous,
chartType, chartType,
interval, interval,
events, series,
lineType, lineType,
metric: 'sum', metric: 'sum',
}} }}

View File

@@ -11,12 +11,14 @@ import {
} from '@openpanel/constants'; } from '@openpanel/constants';
import type { import type {
IChartBreakdown, IChartBreakdown,
IChartEvent, IChartEventItem,
IChartFormula,
IChartLineType, IChartLineType,
IChartProps, IChartProps,
IChartRange, IChartRange,
IChartType, IChartType,
IInterval, IInterval,
UnionOmit,
zCriteria, zCriteria,
} from '@openpanel/validation'; } from '@openpanel/validation';
import type { z } from 'zod'; import type { z } from 'zod';
@@ -39,7 +41,7 @@ const initialState: InitialState = {
lineType: 'monotone', lineType: 'monotone',
interval: 'day', interval: 'day',
breakdowns: [], breakdowns: [],
events: [], series: [],
range: '30d', range: '30d',
startDate: null, startDate: null,
endDate: null, endDate: null,
@@ -86,24 +88,34 @@ export const reportSlice = createSlice({
state.dirty = true; state.dirty = true;
state.name = action.payload; state.name = action.payload;
}, },
// Events // Series (Events and Formulas)
addEvent: (state, action: PayloadAction<Omit<IChartEvent, 'id'>>) => { addSerie: (
state,
action: PayloadAction<UnionOmit<IChartEventItem, 'id'>>,
) => {
state.dirty = true; state.dirty = true;
state.events.push({ state.series.push({
id: shortId(), id: shortId(),
...action.payload, ...action.payload,
}); });
}, },
duplicateEvent: (state, action: PayloadAction<Omit<IChartEvent, 'id'>>) => { duplicateEvent: (state, action: PayloadAction<IChartEventItem>) => {
state.dirty = true; state.dirty = true;
state.events.push({ if (action.payload.type === 'event') {
state.series.push({
...action.payload, ...action.payload,
filters: action.payload.filters.map((filter) => ({ filters: action.payload.filters.map((filter) => ({
...filter, ...filter,
id: shortId(), id: shortId(),
})), })),
id: shortId(), id: shortId(),
}); } as IChartEventItem);
} else {
state.series.push({
...action.payload,
id: shortId(),
} as IChartEventItem);
}
}, },
removeEvent: ( removeEvent: (
state, state,
@@ -112,13 +124,13 @@ export const reportSlice = createSlice({
}>, }>,
) => { ) => {
state.dirty = true; state.dirty = true;
state.events = state.events.filter( state.series = state.series.filter((event) => {
(event) => event.id !== action.payload.id, return event.id !== action.payload.id;
); });
}, },
changeEvent: (state, action: PayloadAction<IChartEvent>) => { changeEvent: (state, action: PayloadAction<IChartEventItem>) => {
state.dirty = true; state.dirty = true;
state.events = state.events.map((event) => { state.series = state.series.map((event) => {
if (event.id === action.payload.id) { if (event.id === action.payload.id) {
return action.payload; return action.payload;
} }
@@ -265,9 +277,9 @@ export const reportSlice = createSlice({
) { ) {
state.dirty = true; state.dirty = true;
const { fromIndex, toIndex } = action.payload; const { fromIndex, toIndex } = action.payload;
const [movedEvent] = state.events.splice(fromIndex, 1); const [movedEvent] = state.series.splice(fromIndex, 1);
if (movedEvent) { if (movedEvent) {
state.events.splice(toIndex, 0, movedEvent); state.series.splice(toIndex, 0, movedEvent);
} }
}, },
}, },
@@ -279,7 +291,7 @@ export const {
ready, ready,
setReport, setReport,
setName, setName,
addEvent, addSerie,
removeEvent, removeEvent,
duplicateEvent, duplicateEvent,
changeEvent, changeEvent,

View File

@@ -1,8 +1,7 @@
import { Combobox } from '@/components/ui/combobox'; import { Combobox } from '@/components/ui/combobox';
import { useAppParams } from '@/hooks/use-app-params'; import { useAppParams } from '@/hooks/use-app-params';
import { useEventProperties } from '@/hooks/use-event-properties'; import { useEventProperties } from '@/hooks/use-event-properties';
import { useDispatch, useSelector } from '@/redux'; import { useDispatch } from '@/redux';
import { api } from '@/trpc/client';
import { cn } from '@/utils/cn'; import { cn } from '@/utils/cn';
import { DatabaseIcon } from 'lucide-react'; import { DatabaseIcon } from 'lucide-react';
@@ -43,6 +42,7 @@ export function EventPropertiesCombobox({
changeEvent({ changeEvent({
...event, ...event,
property: value, property: value,
type: 'event',
}), }),
); );
}} }}

View File

@@ -1,6 +1,8 @@
import { ColorSquare } from '@/components/color-square'; import { ColorSquare } from '@/components/color-square';
import { Button } from '@/components/ui/button';
import { ComboboxEvents } from '@/components/ui/combobox-events'; import { ComboboxEvents } from '@/components/ui/combobox-events';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { InputEnter } from '@/components/ui/input-enter';
import { useAppParams } from '@/hooks/use-app-params'; import { useAppParams } from '@/hooks/use-app-params';
import { useDebounceFn } from '@/hooks/use-debounce-fn'; import { useDebounceFn } from '@/hooks/use-debounce-fn';
import { useEventNames } from '@/hooks/use-event-names'; import { useEventNames } from '@/hooks/use-event-names';
@@ -23,11 +25,11 @@ import {
import { CSS } from '@dnd-kit/utilities'; import { CSS } from '@dnd-kit/utilities';
import { shortId } from '@openpanel/common'; import { shortId } from '@openpanel/common';
import { alphabetIds } from '@openpanel/constants'; import { alphabetIds } from '@openpanel/constants';
import type { IChartEvent } from '@openpanel/validation'; import type { IChartEventItem, IChartFormula } from '@openpanel/validation';
import { FilterIcon, HandIcon } from 'lucide-react'; import { FilterIcon, HandIcon, PlusIcon } from 'lucide-react';
import { ReportSegment } from '../ReportSegment'; import { ReportSegment } from '../ReportSegment';
import { import {
addEvent, addSerie,
changeEvent, changeEvent,
duplicateEvent, duplicateEvent,
removeEvent, removeEvent,
@@ -47,7 +49,7 @@ function SortableEvent({
isSelectManyEvents, isSelectManyEvents,
...props ...props
}: { }: {
event: IChartEvent; event: IChartEventItem;
index: number; index: number;
showSegment: boolean; showSegment: boolean;
showAddFilter: boolean; showAddFilter: boolean;
@@ -62,6 +64,8 @@ function SortableEvent({
transition, transition,
}; };
const isEvent = event.type === 'event';
return ( return (
<div ref={setNodeRef} style={style} {...attributes} {...props}> <div ref={setNodeRef} style={style} {...attributes} {...props}>
<div className="flex items-center gap-2 p-2 group"> <div className="flex items-center gap-2 p-2 group">
@@ -76,8 +80,8 @@ function SortableEvent({
{props.children} {props.children}
</div> </div>
{/* Segment and Filter buttons */} {/* Segment and Filter buttons - only for events */}
{(showSegment || showAddFilter) && ( {isEvent && (showSegment || showAddFilter) && (
<div className="flex gap-2 p-2 pt-0"> <div className="flex gap-2 p-2 pt-0">
{showSegment && ( {showSegment && (
<ReportSegment <ReportSegment
@@ -130,14 +134,14 @@ function SortableEvent({
</div> </div>
)} )}
{/* Filters */} {/* Filters - only for events */}
{!isSelectManyEvents && <FiltersList event={event} />} {isEvent && !isSelectManyEvents && <FiltersList event={event} />}
</div> </div>
); );
} }
export function ReportEvents() { 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 chartType = useSelector((state) => state.report.chartType);
const dispatch = useDispatch(); const dispatch = useDispatch();
const { projectId } = useAppParams(); const { projectId } = useAppParams();
@@ -151,7 +155,7 @@ export function ReportEvents() {
const isAddEventDisabled = const isAddEventDisabled =
(chartType === 'retention' || chartType === 'conversion') && (chartType === 'retention' || chartType === 'conversion') &&
selectedEvents.length >= 2; selectedEvents.length >= 2;
const dispatchChangeEvent = useDebounceFn((event: IChartEvent) => { const dispatchChangeEvent = useDebounceFn((event: IChartEventItem) => {
dispatch(changeEvent(event)); dispatch(changeEvent(event));
}); });
const isSelectManyEvents = chartType === 'retention'; const isSelectManyEvents = chartType === 'retention';
@@ -174,11 +178,15 @@ export function ReportEvents() {
} }
}; };
const handleMore = (event: IChartEvent) => { const handleMore = (event: IChartEventItem) => {
const callback: ReportEventMoreProps['onClick'] = (action) => { const callback: ReportEventMoreProps['onClick'] = (action) => {
switch (action) { switch (action) {
case 'remove': { case 'remove': {
return dispatch(removeEvent(event)); return dispatch(
removeEvent({
id: event.id,
}),
);
} }
case 'duplicate': { case 'duplicate': {
return dispatch(duplicateEvent(event)); return dispatch(duplicateEvent(event));
@@ -189,20 +197,31 @@ export function ReportEvents() {
return callback; return callback;
}; };
const dispatchChangeFormula = useDebounceFn((formula: IChartFormula) => {
dispatch(changeEvent(formula));
});
const showFormula =
chartType !== 'conversion' &&
chartType !== 'funnel' &&
chartType !== 'retention';
return ( return (
<div> <div>
<h3 className="mb-2 font-medium">Events</h3> <h3 className="mb-2 font-medium">Metrics</h3>
<DndContext <DndContext
sensors={sensors} sensors={sensors}
collisionDetection={closestCenter} collisionDetection={closestCenter}
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
> >
<SortableContext <SortableContext
items={selectedEvents.map((e) => ({ id: e.id ?? '' }))} items={selectedEvents.map((e) => ({ id: e.id! }))}
strategy={verticalListSortingStrategy} strategy={verticalListSortingStrategy}
> >
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{selectedEvents.map((event, index) => { {selectedEvents.map((event, index) => {
const isFormula = event.type === 'formula';
return ( return (
<SortableEvent <SortableEvent
key={event.id} key={event.id}
@@ -213,14 +232,44 @@ export function ReportEvents() {
isSelectManyEvents={isSelectManyEvents} isSelectManyEvents={isSelectManyEvents}
className="rounded-lg border bg-def-100" className="rounded-lg border bg-def-100"
> >
{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,
displayName: e.target.value,
});
}}
/>
)}
</div>
<ReportEventMore onClick={handleMore(event)} />
</>
) : (
<>
<ComboboxEvents <ComboboxEvents
className="flex-1" className="flex-1"
searchable searchable
multiple={isSelectManyEvents as false} multiple={isSelectManyEvents as false}
value={ value={
(isSelectManyEvents isSelectManyEvents
? (event.filters[0]?.value ?? []) ? (event.filters[0]?.value ?? [])
: event.name) as any : (event.name as any)
} }
onChange={(value) => { onChange={(value) => {
dispatch( dispatch(
@@ -228,6 +277,7 @@ export function ReportEvents() {
Array.isArray(value) Array.isArray(value)
? { ? {
id: event.id, id: event.id,
type: 'event',
segment: 'user', segment: 'user',
filters: [ filters: [
{ {
@@ -240,6 +290,7 @@ export function ReportEvents() {
} }
: { : {
...event, ...event,
type: 'event',
name: value, name: value,
filters: [], filters: [],
}, },
@@ -266,10 +317,13 @@ export function ReportEvents() {
/> />
)} )}
<ReportEventMore onClick={handleMore(event)} /> <ReportEventMore onClick={handleMore(event)} />
</>
)}
</SortableEvent> </SortableEvent>
); );
})} })}
<div className="flex gap-2">
<ComboboxEvents <ComboboxEvents
disabled={isAddEventDisabled} disabled={isAddEventDisabled}
value={''} value={''}
@@ -277,7 +331,8 @@ export function ReportEvents() {
onChange={(value) => { onChange={(value) => {
if (isSelectManyEvents) { if (isSelectManyEvents) {
dispatch( dispatch(
addEvent({ addSerie({
type: 'event',
segment: 'user', segment: 'user',
name: value, name: value,
filters: [ filters: [
@@ -291,7 +346,8 @@ export function ReportEvents() {
); );
} else { } else {
dispatch( dispatch(
addEvent({ addSerie({
type: 'event',
name: value, name: value,
segment: 'event', segment: 'event',
filters: [], filters: [],
@@ -302,6 +358,25 @@ export function ReportEvents() {
placeholder="Select event" placeholder="Select event"
items={eventNames} items={eventNames}
/> />
{showFormula && (
<Button
type="button"
variant="outline"
icon={PlusIcon}
onClick={() => {
dispatch(
addSerie({
type: 'formula',
formula: '',
displayName: '',
}),
);
}}
>
Add Formula
</Button>
)}
</div>
</div> </div>
</SortableContext> </SortableContext>
</DndContext> </DndContext>

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -3,23 +3,17 @@ import { SheetClose, SheetFooter } from '@/components/ui/sheet';
import { useSelector } from '@/redux'; import { useSelector } from '@/redux';
import { ReportBreakdowns } from './ReportBreakdowns'; import { ReportBreakdowns } from './ReportBreakdowns';
import { ReportEvents } from './ReportEvents'; import { ReportSeries } from './ReportSeries';
import { ReportFormula } from './ReportFormula';
import { ReportSettings } from './ReportSettings'; import { ReportSettings } from './ReportSettings';
export function ReportSidebar() { export function ReportSidebar() {
const { chartType } = useSelector((state) => state.report); const { chartType } = useSelector((state) => state.report);
const showFormula =
chartType !== 'conversion' &&
chartType !== 'funnel' &&
chartType !== 'retention';
const showBreakdown = chartType !== 'retention'; const showBreakdown = chartType !== 'retention';
return ( return (
<> <>
<div className="flex flex-col gap-8"> <div className="flex flex-col gap-8">
<ReportEvents /> <ReportSeries />
{showBreakdown && <ReportBreakdowns />} {showBreakdown && <ReportBreakdowns />}
{showFormula && <ReportFormula />}
<ReportSettings /> <ReportSettings />
</div> </div>
<SheetFooter> <SheetFooter>

View File

@@ -39,14 +39,12 @@ interface PureFilterProps {
} }
export function FilterItem({ filter, event }: FilterProps) { export function FilterItem({ filter, event }: FilterProps) {
// const { range, startDate, endDate, interval } = useSelector(
// (state) => state.report,
// );
const onRemove = ({ id }: IChartEventFilter) => { const onRemove = ({ id }: IChartEventFilter) => {
dispatch( dispatch(
changeEvent({ changeEvent({
...event, ...event,
filters: event.filters.filter((item) => item.id !== id), filters: event.filters.filter((item) => item.id !== id),
type: 'event',
}), }),
); );
}; };
@@ -58,6 +56,7 @@ export function FilterItem({ filter, event }: FilterProps) {
dispatch( dispatch(
changeEvent({ changeEvent({
...event, ...event,
type: 'event',
filters: event.filters.map((item) => { filters: event.filters.map((item) => {
if (item.id === id) { if (item.id === id) {
return { return {
@@ -79,6 +78,7 @@ export function FilterItem({ filter, event }: FilterProps) {
dispatch( dispatch(
changeEvent({ changeEvent({
...event, ...event,
type: 'event',
filters: event.filters.map((item) => { filters: event.filters.map((item) => {
if (item.id === id) { if (item.id === id) {
return { return {

View File

@@ -4,7 +4,7 @@ import { AnimatePresence } from 'framer-motion';
import { RefreshCcwIcon } from 'lucide-react'; import { RefreshCcwIcon } from 'lucide-react';
import { type InputHTMLAttributes, useEffect, useState } from 'react'; import { type InputHTMLAttributes, useEffect, useState } from 'react';
import { Badge } from './badge'; import { Badge } from './badge';
import { Input } from './input'; import { Input, type InputProps } from './input';
export function InputEnter({ export function InputEnter({
value, value,
@@ -13,7 +13,7 @@ export function InputEnter({
}: { }: {
value: string | undefined; value: string | undefined;
onChangeValue: (value: string) => void; onChangeValue: (value: string) => void;
} & InputHTMLAttributes<HTMLInputElement>) { } & InputProps) {
const [internalValue, setInternalValue] = useState(value ?? ''); const [internalValue, setInternalValue] = useState(value ?? '');
useEffect(() => { useEffect(() => {
@@ -33,7 +33,6 @@ export function InputEnter({
onChangeValue(internalValue); onChangeValue(internalValue);
} }
}} }}
size="default"
/> />
<div className="absolute right-2 top-1/2 -translate-y-1/2"> <div className="absolute right-2 top-1/2 -translate-y-1/2">
<AnimatePresence> <AnimatePresence>

View File

@@ -1,8 +1,36 @@
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'; 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'; 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({ function ScrollArea({
className, className,
children, children,

View File

@@ -0,0 +1,44 @@
import type { RouterOutputs } from '@/trpc/client';
import { useMemo } from 'react';
export function useConversionRechartDataModel(
series: RouterOutputs['chart']['conversion']['current'],
) {
return useMemo(() => {
if (!series.length || !series[0]?.data.length) {
return [];
}
// Get all unique dates from the first series (all series should have same dates)
const dates = series[0].data.map((item) => item.date);
return dates.map((date) => {
const baseItem = series[0].data.find((item) => item.date === date);
if (!baseItem) {
return {
date,
timestamp: new Date(date).getTime(),
};
}
// Build data object with all series values
const dataPoint: Record<string, any> = {
date,
timestamp: new Date(date).getTime(),
};
series.forEach((serie) => {
const item = serie.data.find((d) => d.date === date);
if (item) {
dataPoint[`${serie.id}:rate`] = item.rate;
dataPoint[`${serie.id}:previousRate`] = item.previousRate;
dataPoint[`${serie.id}:total`] = item.total;
dataPoint[`${serie.id}:conversions`] = item.conversions;
}
});
return dataPoint;
});
}, [series]);
}

View File

@@ -0,0 +1,35 @@
import type { RouterOutputs } from '@/trpc/client';
import { useEffect, useMemo, useState } from 'react';
export type IVisibleConversionSeries = ReturnType<
typeof useVisibleConversionSeries
>['series'];
export function useVisibleConversionSeries(
data: RouterOutputs['chart']['conversion'],
limit?: number | undefined,
) {
const max = limit ?? 5;
const [visibleSeries, setVisibleSeries] = useState<string[]>(
data?.current?.slice(0, max).map((serie) => serie.id) ?? [],
);
useEffect(() => {
setVisibleSeries(
data?.current?.slice(0, max).map((serie) => serie.id) ?? [],
);
}, [data, max]);
return useMemo(() => {
return {
series: data.current
.map((serie, index) => ({
...serie,
index,
}))
.filter((serie) => visibleSeries.includes(serie.id)),
setVisibleSeries,
} as const;
}, [visibleSeries, data.current]);
}

View File

@@ -0,0 +1,44 @@
import { ScrollArea, VirtualScrollArea } from '@/components/ui/scroll-area';
import { cn } from '@/utils/cn';
import { createContext, useContext, useRef } from 'react';
import { ModalContent } from './Container';
const ScrollableModalContext = createContext<{
scrollAreaRef: React.RefObject<HTMLDivElement | null>;
}>({
scrollAreaRef: { current: null },
});
export function useScrollableModal() {
return useContext(ScrollableModalContext);
}
export function ScrollableModal({
header,
footer,
children,
}: {
header: React.ReactNode;
footer?: React.ReactNode;
children: React.ReactNode;
}) {
const scrollAreaRef = useRef<HTMLDivElement>(null);
return (
<ScrollableModalContext.Provider value={{ scrollAreaRef }}>
<ModalContent className="flex !max-h-[90vh] flex-col p-0 gap-0">
<div className="flex-shrink-0 p-6">{header}</div>
<VirtualScrollArea
ref={scrollAreaRef}
className={cn(
'flex-1 min-h-0 w-full',
footer && 'border-b',
header && 'border-t',
)}
>
{children}
</VirtualScrollArea>
{footer && <div className="flex-shrink-0 p-6">{footer}</div>}
</ModalContent>
</ScrollableModalContext.Provider>
);
}

View File

@@ -341,13 +341,14 @@ export default function EventDetails({ id, createdAt, projectId }: Props) {
<ReportChartShortcut <ReportChartShortcut
projectId={event.projectId} projectId={event.projectId}
chartType="linear" chartType="linear"
events={[ series={[
{ {
id: 'A', id: 'A',
name: event.name, name: event.name,
displayName: 'Similar events', displayName: 'Similar events',
segment: 'event', segment: 'event',
filters: [], filters: [],
type: 'event',
}, },
]} ]}
/> />

View File

@@ -31,6 +31,7 @@ import RequestPasswordReset from './request-reset-password';
import SaveReport from './save-report'; import SaveReport from './save-report';
import SelectBillingPlan from './select-billing-plan'; import SelectBillingPlan from './select-billing-plan';
import ShareOverviewModal from './share-overview-modal'; import ShareOverviewModal from './share-overview-modal';
import ViewChartUsers from './view-chart-users';
const modals = { const modals = {
OverviewTopPagesModal: OverviewTopPagesModal, OverviewTopPagesModal: OverviewTopPagesModal,
@@ -51,6 +52,7 @@ const modals = {
EditReference: EditReference, EditReference: EditReference,
ShareOverviewModal: ShareOverviewModal, ShareOverviewModal: ShareOverviewModal,
AddReference: AddReference, AddReference: AddReference,
ViewChartUsers: ViewChartUsers,
Instructions: Instructions, Instructions: Instructions,
OnboardingTroubleshoot: OnboardingTroubleshoot, OnboardingTroubleshoot: OnboardingTroubleshoot,
DateRangerPicker: DateRangerPicker, DateRangerPicker: DateRangerPicker,

View File

@@ -0,0 +1,398 @@
import { ProjectLink } from '@/components/links';
import { ProfileAvatar } from '@/components/profiles/profile-avatar';
import { SerieIcon } from '@/components/report-chart/common/serie-icon';
import { DropdownMenuShortcut } from '@/components/ui/dropdown-menu';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useTRPC } from '@/integrations/trpc/react';
import type { IChartData } from '@/trpc/client';
import { cn } from '@/utils/cn';
import { getProfileName } from '@/utils/getters';
import type { IChartInput } from '@openpanel/validation';
import { useQuery } from '@tanstack/react-query';
import { useVirtualizer } from '@tanstack/react-virtual';
import { useEffect, useMemo, useState } from 'react';
import { popModal } from '.';
import { ModalHeader } from './Modal/Container';
import { ScrollableModal, useScrollableModal } from './Modal/scrollable-modal';
const ProfileItem = ({ profile }: { profile: any }) => {
return (
<ProjectLink
preload={false}
href={`/profiles/${profile.id}`}
title={getProfileName(profile, false)}
className="col gap-2 rounded-lg border p-2 bg-card"
onClick={(e) => {
if (e.metaKey || e.ctrlKey || e.shiftKey) {
return;
}
popModal();
}}
>
<div className="row gap-2 items-center">
<ProfileAvatar {...profile} />
<div className="flex-1">
<div className="font-medium">{getProfileName(profile)}</div>
</div>
</div>
<div className="row gap-4 text-sm overflow-hidden">
{profile.properties.country && (
<div className="row gap-2 items-center">
<SerieIcon name={profile.properties.country} />
<span>
{profile.properties.country}
{profile.properties.city && ` / ${profile.properties.city}`}
</span>
</div>
)}
{profile.properties.os && (
<div className="row gap-2 items-center">
<SerieIcon name={profile.properties.os} />
<span>{profile.properties.os}</span>
</div>
)}
{profile.properties.browser && (
<div className="row gap-2 items-center">
<SerieIcon name={profile.properties.browser} />
<span>{profile.properties.browser}</span>
</div>
)}
</div>
</ProjectLink>
);
};
// Shared profile list component
function ProfileList({ profiles }: { profiles: any[] }) {
const ITEM_HEIGHT = 74;
const CONTAINER_PADDING = 20;
const ITEM_GAP = 5;
const { scrollAreaRef } = useScrollableModal();
const [isScrollReady, setIsScrollReady] = useState(false);
// Check if scroll container is ready
useEffect(() => {
if (scrollAreaRef.current) {
setIsScrollReady(true);
} else {
setIsScrollReady(false);
}
}, [scrollAreaRef]);
const virtualizer = useVirtualizer({
count: profiles.length,
getScrollElement: () => scrollAreaRef.current,
estimateSize: () => ITEM_HEIGHT + ITEM_GAP,
overscan: 5,
paddingStart: CONTAINER_PADDING,
paddingEnd: CONTAINER_PADDING,
});
// Re-measure when scroll container becomes available or profiles change
useEffect(() => {
if (isScrollReady && scrollAreaRef.current) {
// Small delay to ensure DOM is ready
const timeoutId = setTimeout(() => {
virtualizer.measure();
}, 0);
return () => clearTimeout(timeoutId);
}
}, [isScrollReady, profiles.length, virtualizer]);
if (profiles.length === 0) {
return (
<div className="flex items-center justify-center py-8">
<div className="text-muted-foreground">No users found</div>
</div>
);
}
const virtualItems = virtualizer.getVirtualItems();
return (
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{/* Only the visible items in the virtualizer, manually positioned to be in view */}
{virtualItems.map((virtualItem) => {
const profile = profiles[virtualItem.index];
return (
<div
key={profile.id}
data-index={virtualItem.index}
ref={virtualizer.measureElement}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`,
padding: `0px ${CONTAINER_PADDING}px ${ITEM_GAP}px`,
}}
>
<ProfileItem profile={profile} />
</div>
);
})}
</div>
);
}
// Chart-specific props and component
interface ChartUsersViewProps {
chartData: IChartData;
report: IChartInput;
date: string;
}
function ChartUsersView({ chartData, report, date }: ChartUsersViewProps) {
const trpc = useTRPC();
const [selectedSerieId, setSelectedSerieId] = useState<string | null>(
report.series[0]?.id || null,
);
const [selectedBreakdownId, setSelectedBreakdownId] = useState<string | null>(
null,
);
const selectedReportSerie = useMemo(
() => report.series.find((s) => s.id === selectedSerieId),
[report.series, selectedSerieId],
);
// Get all chart series that match the selected report serie
const matchingChartSeries = useMemo(() => {
if (!selectedSerieId || !chartData) return [];
return chartData.series.filter((s) => s.event.id === selectedSerieId);
}, [chartData?.series, selectedSerieId]);
const selectedBreakdown = useMemo(() => {
if (!selectedBreakdownId) return null;
return matchingChartSeries.find((s) => s.id === selectedBreakdownId);
}, [matchingChartSeries, selectedBreakdownId]);
// Reset breakdown selection when serie changes
const handleSerieChange = (value: string) => {
setSelectedSerieId(value);
setSelectedBreakdownId(null);
};
const profilesQuery = useQuery(
trpc.chart.getProfiles.queryOptions(
{
projectId: report.projectId,
date: date,
series:
selectedReportSerie && selectedReportSerie.type === 'event'
? [selectedReportSerie]
: [],
breakdowns: selectedBreakdown?.event.breakdowns,
interval: report.interval,
},
{
enabled: !!selectedReportSerie && selectedReportSerie.type === 'event',
},
),
);
const profiles = profilesQuery.data ?? [];
return (
<ScrollableModal
header={
<div>
<ModalHeader
title="View Users"
text={`Users who performed actions on ${new Date(date).toLocaleDateString()}`}
/>
{report.series.length > 0 && (
<div className="col md:row gap-2">
<Select
value={selectedSerieId || ''}
onValueChange={handleSerieChange}
>
<SelectTrigger className="flex-1">
<SelectValue placeholder="Select Serie" />
</SelectTrigger>
<SelectContent>
{report.series.map((serie) => (
<SelectItem key={serie.id} value={serie.id || ''}>
{serie.type === 'event'
? serie.displayName || serie.name
: serie.displayName || 'Formula'}
</SelectItem>
))}
</SelectContent>
</Select>
{matchingChartSeries.length > 1 && (
<Select
value={selectedBreakdownId || ''}
onValueChange={(value) => setSelectedBreakdownId(value)}
>
<SelectTrigger className="flex-1">
<SelectValue placeholder="Select Breakdown" />
</SelectTrigger>
<SelectContent>
{matchingChartSeries
.sort((a, b) => b.metrics.sum - a.metrics.sum)
.map((serie) => (
<SelectItem key={serie.id} value={serie.id}>
{Object.values(serie.event.breakdowns ?? {}).join(
', ',
)}
<DropdownMenuShortcut className="ml-auto">
({serie.data.find((d) => d.date === date)?.count})
</DropdownMenuShortcut>
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
)}
</div>
}
>
<div className="col">
{profilesQuery.isLoading ? (
<div className="flex items-center justify-center py-8">
<div className="text-muted-foreground">Loading users...</div>
</div>
) : (
<ProfileList profiles={profiles} />
)}
</div>
</ScrollableModal>
);
}
// Funnel-specific props and component
interface FunnelUsersViewProps {
report: IChartInput;
stepIndex: number;
}
function FunnelUsersView({ report, stepIndex }: FunnelUsersViewProps) {
const trpc = useTRPC();
const [showDropoffs, setShowDropoffs] = useState(false);
const profilesQuery = useQuery(
trpc.chart.getFunnelProfiles.queryOptions(
{
projectId: report.projectId,
startDate: report.startDate!,
endDate: report.endDate!,
range: report.range,
series: report.series,
stepIndex: stepIndex,
showDropoffs: showDropoffs,
funnelWindow: report.funnelWindow,
funnelGroup: report.funnelGroup,
breakdowns: report.breakdowns,
},
{
enabled: stepIndex !== undefined,
},
),
);
const profiles = profilesQuery.data ?? [];
const isLastStep = stepIndex === report.series.length - 1;
return (
<ScrollableModal
header={
<div className="flex flex-col gap-2">
<ModalHeader
title="View Users"
text={
showDropoffs
? `Users who dropped off after step ${stepIndex + 1} of ${report.series.length}`
: `Users who completed step ${stepIndex + 1} of ${report.series.length} in the funnel`
}
/>
{!isLastStep && (
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setShowDropoffs(false)}
className={cn(
'px-3 py-1.5 text-sm rounded-md transition-colors',
!showDropoffs
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground hover:bg-muted/80',
)}
>
Completed
</button>
<button
type="button"
onClick={() => setShowDropoffs(true)}
className={cn(
'px-3 py-1.5 text-sm rounded-md transition-colors',
showDropoffs
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground hover:bg-muted/80',
)}
>
Dropped Off
</button>
</div>
)}
</div>
}
>
<div className="flex flex-col gap-4">
{profilesQuery.isLoading ? (
<div className="flex items-center justify-center py-8">
<div className="text-muted-foreground">Loading users...</div>
</div>
) : (
<ProfileList profiles={profiles} />
)}
</div>
</ScrollableModal>
);
}
// Union type for props
type ViewChartUsersProps =
| {
type: 'chart';
chartData: IChartData;
report: IChartInput;
date: string;
}
| {
type: 'funnel';
report: IChartInput;
stepIndex: number;
};
// Main component that routes to the appropriate view
export default function ViewChartUsers(props: ViewChartUsersProps) {
if (props.type === 'funnel') {
return (
<FunnelUsersView report={props.report} stepIndex={props.stepIndex} />
);
}
return (
<ChartUsersView
chartData={props.chartData}
report={props.report}
date={props.date}
/>
);
}

View File

@@ -9,7 +9,7 @@ import {
useEventQueryNamesFilter, useEventQueryNamesFilter,
} from '@/hooks/use-event-query-filters'; } from '@/hooks/use-event-query-filters';
import type { IChartEvent } from '@openpanel/validation'; import type { IChartEventItem } from '@openpanel/validation';
import { createFileRoute } from '@tanstack/react-router'; import { createFileRoute } from '@tanstack/react-router';
@@ -23,13 +23,14 @@ function Component() {
const { projectId } = Route.useParams(); const { projectId } = Route.useParams();
const [filters] = useEventQueryFilters(); const [filters] = useEventQueryFilters();
const [events] = useEventQueryNamesFilter(); const [events] = useEventQueryNamesFilter();
const fallback: IChartEvent[] = [ const fallback: IChartEventItem[] = [
{ {
id: 'A', id: 'A',
name: '*', name: '*',
displayName: 'All events', displayName: 'All events',
segment: 'event', segment: 'event',
filters: filters ?? [], filters: filters ?? [],
type: 'event',
}, },
]; ];
@@ -49,7 +50,7 @@ function Component() {
projectId={projectId} projectId={projectId}
range="30d" range="30d"
chartType="histogram" chartType="histogram"
events={ series={
events && events.length > 0 events && events.length > 0
? events.map((name) => ({ ? events.map((name) => ({
id: name, id: name,
@@ -57,6 +58,7 @@ function Component() {
displayName: name, displayName: name,
segment: 'event', segment: 'event',
filters: filters ?? [], filters: filters ?? [],
type: 'event',
})) }))
: fallback : fallback
} }
@@ -69,6 +71,11 @@ function Component() {
</WidgetHead> </WidgetHead>
<WidgetBody> <WidgetBody>
<ReportChartShortcut <ReportChartShortcut
options={{
renderSerieName(names) {
return names[1];
},
}}
projectId={projectId} projectId={projectId}
range="30d" range="30d"
chartType="pie" chartType="pie"
@@ -78,7 +85,7 @@ function Component() {
name: 'name', name: 'name',
}, },
]} ]}
events={ series={
events && events.length > 0 events && events.length > 0
? events.map((name) => ({ ? events.map((name) => ({
id: name, id: name,
@@ -86,6 +93,7 @@ function Component() {
displayName: name, displayName: name,
segment: 'event', segment: 'event',
filters: filters ?? [], filters: filters ?? [],
type: 'event',
})) }))
: [ : [
{ {
@@ -94,6 +102,7 @@ function Component() {
displayName: 'All events', displayName: 'All events',
segment: 'event', segment: 'event',
filters: filters ?? [], filters: filters ?? [],
type: 'event',
}, },
] ]
} }
@@ -106,6 +115,11 @@ function Component() {
</WidgetHead> </WidgetHead>
<WidgetBody> <WidgetBody>
<ReportChartShortcut <ReportChartShortcut
options={{
renderSerieName(names) {
return names[1];
},
}}
projectId={projectId} projectId={projectId}
range="30d" range="30d"
chartType="bar" chartType="bar"
@@ -115,7 +129,7 @@ function Component() {
name: 'name', name: 'name',
}, },
]} ]}
events={ series={
events && events.length > 0 events && events.length > 0
? events.map((name) => ({ ? events.map((name) => ({
id: name, id: name,
@@ -123,6 +137,7 @@ function Component() {
displayName: name, displayName: name,
segment: 'event', segment: 'event',
filters: filters ?? [], filters: filters ?? [],
type: 'event',
})) }))
: [ : [
{ {
@@ -131,6 +146,7 @@ function Component() {
displayName: 'All events', displayName: 'All events',
segment: 'event', segment: 'event',
filters: filters ?? [], filters: filters ?? [],
type: 'event',
}, },
] ]
} }
@@ -143,6 +159,11 @@ function Component() {
</WidgetHead> </WidgetHead>
<WidgetBody> <WidgetBody>
<ReportChartShortcut <ReportChartShortcut
options={{
renderSerieName(names) {
return names[1];
},
}}
projectId={projectId} projectId={projectId}
range="30d" range="30d"
chartType="linear" chartType="linear"
@@ -152,7 +173,7 @@ function Component() {
name: 'name', name: 'name',
}, },
]} ]}
events={ series={
events && events.length > 0 events && events.length > 0
? events.map((name) => ({ ? events.map((name) => ({
id: name, id: name,
@@ -160,6 +181,7 @@ function Component() {
displayName: name, displayName: name,
segment: 'event', segment: 'event',
filters: filters ?? [], filters: filters ?? [],
type: 'event',
})) }))
: [ : [
{ {
@@ -168,6 +190,7 @@ function Component() {
displayName: 'All events', displayName: 'All events',
segment: 'event', segment: 'event',
filters: filters ?? [], filters: filters ?? [],
type: 'event',
}, },
] ]
} }

View File

@@ -24,8 +24,7 @@ import { createFileRoute } from '@tanstack/react-router';
import { parseAsInteger, useQueryState } from 'nuqs'; import { parseAsInteger, useQueryState } from 'nuqs';
import { memo } from 'react'; import { memo } from 'react';
export const Route = createFileRoute('/_app/$organizationId/$projectId/pages')( export const Route = createFileRoute('/_app/$organizationId/$projectId/pages')({
{
component: Component, component: Component,
head: () => { head: () => {
return { return {
@@ -36,8 +35,7 @@ export const Route = createFileRoute('/_app/$organizationId/$projectId/pages')(
], ],
}; };
}, },
}, });
);
function Component() { function Component() {
const { projectId } = Route.useParams(); const { projectId } = Route.useParams();
@@ -220,8 +218,9 @@ const PageCard = memo(
chartType: 'linear', chartType: 'linear',
projectId, projectId,
events: [ series: [
{ {
type: 'event',
id: 'A', id: 'A',
name: 'screen_view', name: 'screen_view',
segment: 'event', segment: 'event',

View File

@@ -340,3 +340,37 @@ button {
.animate-ping-slow { .animate-ping-slow {
animation: ping-slow 2s cubic-bezier(0, 0, 0.2, 1) infinite; animation: ping-slow 2s cubic-bezier(0, 0, 0.2, 1) infinite;
} }
.scrollarea {
position: relative;
width: 100%;
height: 300px; /* set any height */
overflow: hidden; /* hide native scrollbars */
}
.scrollarea-content {
height: 100%;
width: 100%;
overflow-y: scroll;
padding-right: 16px; /* preserve space for custom scrollbar */
scrollbar-width: none; /* hide Firefox scrollbar */
}
.scrollarea-content::-webkit-scrollbar {
width: 8px; /* size of custom scrollbar */
}
.scrollarea-content::-webkit-scrollbar-track {
background: transparent; /* no visible track, like shadcn */
}
.scrollarea-content::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.2);
border-radius: 9999px; /* fully rounded */
transition: background 0.2s;
}
.scrollarea-content:hover::-webkit-scrollbar-thumb,
.scrollarea-content:active::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.4); /* darken on hover/scroll */
}

View File

@@ -23,9 +23,12 @@ const chartColors = [
]; ];
export function getChartColor(index: number): string { export function getChartColor(index: number): string {
return chartColors[index % chartColors.length]!.main; return chartColors[index % chartColors.length]?.main || chartColors[0].main;
} }
export function getChartTranslucentColor(index: number): string { export function getChartTranslucentColor(index: number): string {
return chartColors[index % chartColors.length]!.translucent; return (
chartColors[index % chartColors.length]?.translucent ||
chartColors[0].translucent
);
} }

View File

@@ -0,0 +1,104 @@
import { shortId } from '@openpanel/common';
import type {
IChartEvent,
IChartEventItem,
IChartFormula,
} from '@openpanel/validation';
import { db } from '../index';
import { printBoxMessage } from './helpers';
export async function up() {
printBoxMessage('🔄 Migrating Events to Series Format', []);
// Get all reports
const reports = await db.report.findMany({
select: {
id: true,
events: true,
formula: true,
name: true,
},
});
let migratedCount = 0;
let skippedCount = 0;
let formulaAddedCount = 0;
for (const report of reports) {
const events = report.events as unknown as Array<
Partial<IChartEventItem> | Partial<IChartEvent>
>;
const oldFormula = report.formula;
// Check if any event is missing the 'type' field (old format)
const needsEventMigration =
Array.isArray(events) &&
events.length > 0 &&
events.some(
(event) => !event || typeof event !== 'object' || !('type' in event),
);
// Check if formula exists and isn't already in the series
const hasFormulaInSeries =
Array.isArray(events) &&
events.some(
(item) =>
item &&
typeof item === 'object' &&
'type' in item &&
item.type === 'formula',
);
const needsFormulaMigration = !!oldFormula && !hasFormulaInSeries;
// Skip if no migration needed
if (!needsEventMigration && !needsFormulaMigration) {
skippedCount++;
continue;
}
// Transform events to new format: add type: 'event' to each event
const migratedSeries: IChartEventItem[] = Array.isArray(events)
? events.map((event) => {
if (event && typeof event === 'object' && 'type' in event) {
return event as IChartEventItem;
}
return {
...event,
type: 'event',
} as IChartEventItem;
})
: [];
// Add formula to series if it exists and isn't already there
if (needsFormulaMigration && oldFormula) {
const formulaItem: IChartFormula = {
type: 'formula',
formula: oldFormula,
id: shortId(),
};
migratedSeries.push(formulaItem);
formulaAddedCount++;
}
console.log(
`Updating report ${report.name} (${report.id}) with ${migratedSeries.length} series`,
);
// Update the report with migrated series
await db.report.update({
where: { id: report.id },
data: {
events: migratedSeries,
},
});
migratedCount++;
}
printBoxMessage('✅ Migration Complete', [
`Migrated: ${migratedCount} reports`,
`Formulas added: ${formulaAddedCount} reports`,
`Skipped: ${skippedCount} reports (already in new format or empty)`,
]);
}

View File

@@ -3,6 +3,7 @@ export * from './src/clickhouse/client';
export * from './src/clickhouse/csv'; export * from './src/clickhouse/csv';
export * from './src/sql-builder'; export * from './src/sql-builder';
export * from './src/services/chart.service'; export * from './src/services/chart.service';
export * from './src/engine';
export * from './src/services/clients.service'; export * from './src/services/clients.service';
export * from './src/services/dashboard.service'; export * from './src/services/dashboard.service';
export * from './src/services/event.service'; export * from './src/services/event.service';

View File

@@ -25,6 +25,7 @@
"@prisma/extension-read-replicas": "^0.4.1", "@prisma/extension-read-replicas": "^0.4.1",
"fast-deep-equal": "^3.1.3", "fast-deep-equal": "^3.1.3",
"jiti": "^2.4.1", "jiti": "^2.4.1",
"mathjs": "^12.3.2",
"prisma-json-types-generator": "^3.1.1", "prisma-json-types-generator": "^3.1.1",
"ramda": "^0.29.1", "ramda": "^0.29.1",
"sqlstring": "^2.3.3", "sqlstring": "^2.3.3",

View File

@@ -0,0 +1,112 @@
import { stdin as input, stdout as output } from 'node:process';
import { createInterface } from 'node:readline/promises';
import { parseArgs } from 'node:util';
import sqlstring from 'sqlstring';
import { ch } from '../src/clickhouse/client';
import { clix } from '../src/clickhouse/query-builder';
async function main() {
const rl = createInterface({ input, output });
try {
const { values } = parseArgs({
args: process.argv.slice(2),
options: {
host: { type: 'string' },
user: { type: 'string' },
password: { type: 'string' },
db: { type: 'string' },
start: { type: 'string' },
end: { type: 'string' },
projects: { type: 'string' },
},
strict: false,
});
const getArg = (val: unknown): string | undefined =>
typeof val === 'string' ? val : undefined;
console.log('Copy data from remote ClickHouse to local');
console.log('---------------------------------------');
const host =
getArg(values.host) || (await rl.question('Remote Host (IP/Domain): '));
if (!host) throw new Error('Host is required');
const user = getArg(values.user) || (await rl.question('Remote User: '));
if (!user) throw new Error('User is required');
const password =
getArg(values.password) || (await rl.question('Remote Password: '));
if (!password) throw new Error('Password is required');
const dbName =
getArg(values.db) ||
(await rl.question('Remote DB Name (default: openpanel): ')) ||
'openpanel';
const startDate =
getArg(values.start) ||
(await rl.question('Start Date (YYYY-MM-DD HH:mm:ss): '));
if (!startDate) throw new Error('Start date is required');
const endDate =
getArg(values.end) ||
(await rl.question('End Date (YYYY-MM-DD HH:mm:ss): '));
if (!endDate) throw new Error('End date is required');
const projectIdsInput =
getArg(values.projects) ||
(await rl.question(
'Project IDs (comma separated, leave empty for all): ',
));
const projectIds = projectIdsInput
? projectIdsInput.split(',').map((s: string) => s.trim())
: [];
console.log('\nStarting copy process...');
const tables = ['sessions', 'events'];
for (const table of tables) {
console.log(`Processing table: ${table}`);
// Build the SELECT part using the query builder
// We use sqlstring to escape the remote function arguments
const remoteTable = `remote(${sqlstring.escape(host)}, ${sqlstring.escape(dbName)}, ${sqlstring.escape(table)}, ${sqlstring.escape(user)}, ${sqlstring.escape(password)})`;
const queryBuilder = clix(ch)
.from(remoteTable)
.select(['*'])
.where('created_at', 'BETWEEN', [startDate, endDate]);
if (projectIds.length > 0) {
queryBuilder.where('project_id', 'IN', projectIds);
}
const selectQuery = queryBuilder.toSQL();
const insertQuery = `INSERT INTO ${dbName}.${table} ${selectQuery}`;
console.log(`Executing: ${insertQuery}`);
// try {
// await ch.command({
// query: insertQuery,
// });
// console.log(`✅ Copied ${table} successfully`);
// } catch (error) {
// console.error(`❌ Failed to copy ${table}:`, error);
// }
}
console.log('\nDone!');
} catch (error) {
console.error('\nError:', error);
} finally {
rl.close();
await ch.close();
process.exit(0);
}
}
main();

View File

@@ -0,0 +1,96 @@
import { TABLE_NAMES, ch } from '../src/clickhouse/client';
import { clix } from '../src/clickhouse/query-builder';
const START_DATE = new Date('2025-11-10T00:00:00Z');
const END_DATE = new Date('2025-11-20T23:00:00Z');
const SESSIONS_PER_HOUR = 2;
// Revenue between $10 (1000 cents) and $200 (20000 cents)
const MIN_REVENUE = 1000;
const MAX_REVENUE = 20000;
function getRandomRevenue() {
return (
Math.floor(Math.random() * (MAX_REVENUE - MIN_REVENUE + 1)) + MIN_REVENUE
);
}
async function main() {
console.log(
`Starting revenue update for sessions between ${START_DATE.toISOString()} and ${END_DATE.toISOString()}`,
);
let currentDate = new Date(START_DATE);
while (currentDate < END_DATE) {
const nextHour = new Date(currentDate.getTime() + 60 * 60 * 1000);
console.log(`Processing hour: ${currentDate.toISOString()}`);
// 1. Pick random sessions for this hour
const sessions = await clix(ch)
.from(TABLE_NAMES.sessions)
.select(['id'])
.where('created_at', '>=', currentDate)
.andWhere('created_at', '<', nextHour)
.where('project_id', '=', 'public-web')
.limit(SESSIONS_PER_HOUR)
.execute();
if (sessions.length === 0) {
console.log(`No sessions found for ${currentDate.toISOString()}`);
currentDate = nextHour;
continue;
}
const sessionIds = sessions.map((s: any) => s.id);
console.log(
`Found ${sessionIds.length} sessions to update: ${sessionIds.join(', ')}`,
);
// 2. Construct update query
// We want to assign a DIFFERENT random revenue to each session
// Query: ALTER TABLE sessions UPDATE revenue = if(id='id1', rev1, if(id='id2', rev2, ...)) WHERE id IN ('id1', 'id2', ...)
const updates: { id: string; revenue: number }[] = [];
for (const id of sessionIds) {
const revenue = getRandomRevenue();
updates.push({ id, revenue });
}
// Build nested if() for the update expression
// ClickHouse doesn't have CASE WHEN in UPDATE expression in the same way, but if() works.
// Actually multiIf is cleaner: multiIf(id='id1', rev1, id='id2', rev2, revenue)
const conditions = updates
.map((u) => `id = '${u.id}', ${u.revenue}`)
.join(', ');
const updateExpr = `multiIf(${conditions}, revenue)`;
const idsStr = sessionIds.map((id: string) => `'${id}'`).join(', ');
const query = `ALTER TABLE ${TABLE_NAMES.sessions} UPDATE revenue = ${updateExpr} WHERE id IN (${idsStr})`;
console.log(`Executing update: ${query}`);
try {
await ch.command({
query,
});
console.log('Update command sent.');
// Wait a bit to not overload mutations if running on a large range
await new Promise((resolve) => setTimeout(resolve, 500));
} catch (error) {
console.error('Failed to update sessions:', error);
}
currentDate = nextHour;
}
console.log('Done!');
}
main().catch((error) => {
console.error('Script failed:', error);
process.exit(1);
});

View File

@@ -731,6 +731,7 @@ clix.toInterval = (node: string, interval: IInterval) => {
}; };
clix.toDate = (node: string, interval: IInterval) => { clix.toDate = (node: string, interval: IInterval) => {
switch (interval) { switch (interval) {
case 'day':
case 'week': case 'week':
case 'month': { case 'month': {
return `toDate(${node})`; return `toDate(${node})`;

View File

@@ -0,0 +1,216 @@
import { round } from '@openpanel/common';
import { alphabetIds } from '@openpanel/constants';
import type { IChartFormula } from '@openpanel/validation';
import * as mathjs from 'mathjs';
import type { ConcreteSeries } from './types';
/**
* Compute formula series from fetched event series
* Formulas reference event series using alphabet IDs (A, B, C, etc.)
*/
export function compute(
fetchedSeries: ConcreteSeries[],
definitions: Array<{
type: 'event' | 'formula';
id?: string;
formula?: string;
}>,
): ConcreteSeries[] {
const results: ConcreteSeries[] = [...fetchedSeries];
// Process formulas in order (they can reference previous formulas)
definitions.forEach((definition, formulaIndex) => {
if (definition.type !== 'formula') {
return;
}
const formula = definition as IChartFormula;
if (!formula.formula) {
return;
}
// Group ALL series (events + previously computed formulas) by breakdown signature
// Series with the same breakdown values should be computed together
const seriesByBreakdown = new Map<string, ConcreteSeries[]>();
// Include both fetched event series AND previously computed formulas
const allSeries = [
...fetchedSeries,
...results.filter((s) => s.definitionIndex < formulaIndex),
];
allSeries.forEach((serie) => {
// Create breakdown signature: skip first name part (event/formula name) and use breakdown values
// If name.length === 1, it means no breakdowns (just event name)
// If name.length > 1, name[0] is event name, name[1+] are breakdown values
const breakdownSignature =
serie.name.length > 1 ? serie.name.slice(1).join(':::') : '';
if (!seriesByBreakdown.has(breakdownSignature)) {
seriesByBreakdown.set(breakdownSignature, []);
}
seriesByBreakdown.get(breakdownSignature)!.push(serie);
});
// Compute formula for each breakdown group
for (const [breakdownSignature, breakdownSeries] of seriesByBreakdown) {
// Map series by their definition index for formula evaluation
const seriesByIndex = new Map<number, ConcreteSeries>();
breakdownSeries.forEach((serie) => {
seriesByIndex.set(serie.definitionIndex, serie);
});
// Get all unique dates across all series in this breakdown group
const allDates = new Set<string>();
breakdownSeries.forEach((serie) => {
serie.data.forEach((item) => {
allDates.add(item.date);
});
});
const sortedDates = Array.from(allDates).sort(
(a, b) => new Date(a).getTime() - new Date(b).getTime(),
);
// Calculate total_count for the formula using the same formula applied to input series' total_count values
// total_count is constant across all dates for a breakdown group, so compute it once
const totalCountScope: Record<string, number> = {};
definitions.slice(0, formulaIndex).forEach((depDef, depIndex) => {
const readableId = alphabetIds[depIndex];
if (!readableId) {
return;
}
// Find the series for this dependency in the current breakdown group
const depSeries = seriesByIndex.get(depIndex);
if (depSeries) {
// Get total_count from any data point (it's the same for all dates)
const totalCount = depSeries.data.find(
(d) => d.total_count != null,
)?.total_count;
totalCountScope[readableId] = totalCount ?? 0;
} else {
// Could be a formula from a previous breakdown group - find it in results
const formulaSerie = results.find(
(s) =>
s.definitionIndex === depIndex &&
'type' in s.definition &&
s.definition.type === 'formula' &&
s.name.slice(1).join(':::') === breakdownSignature,
);
if (formulaSerie) {
const totalCount = formulaSerie.data.find(
(d) => d.total_count != null,
)?.total_count;
totalCountScope[readableId] = totalCount ?? 0;
} else {
totalCountScope[readableId] = 0;
}
}
});
// Evaluate formula for total_count
let formulaTotalCount: number | undefined;
try {
const result = mathjs
.parse(formula.formula)
.compile()
.evaluate(totalCountScope) as number;
formulaTotalCount =
Number.isNaN(result) || !Number.isFinite(result)
? undefined
: round(result, 2);
} catch (error) {
formulaTotalCount = undefined;
}
// Calculate formula for each date
const formulaData = sortedDates.map((date) => {
const scope: Record<string, number> = {};
// Build scope using alphabet IDs (A, B, C, etc.)
definitions.slice(0, formulaIndex).forEach((depDef, depIndex) => {
const readableId = alphabetIds[depIndex];
if (!readableId) {
return;
}
// Find the series for this dependency in the current breakdown group
const depSeries = seriesByIndex.get(depIndex);
if (depSeries) {
const dataPoint = depSeries.data.find((d) => d.date === date);
scope[readableId] = dataPoint?.count ?? 0;
} else {
// Could be a formula from a previous breakdown group - find it in results
// Match by definitionIndex AND breakdown signature
const formulaSerie = results.find(
(s) =>
s.definitionIndex === depIndex &&
'type' in s.definition &&
s.definition.type === 'formula' &&
s.name.slice(1).join(':::') === breakdownSignature,
);
if (formulaSerie) {
const dataPoint = formulaSerie.data.find((d) => d.date === date);
scope[readableId] = dataPoint?.count ?? 0;
} else {
scope[readableId] = 0;
}
}
});
// Evaluate formula
let count: number;
try {
count = mathjs
.parse(formula.formula)
.compile()
.evaluate(scope) as number;
} catch (error) {
count = 0;
}
return {
date,
count:
Number.isNaN(count) || !Number.isFinite(count)
? 0
: round(count, 2),
total_count: formulaTotalCount,
};
});
// Create concrete series for this formula
const templateSerie = breakdownSeries[0]!;
// Extract breakdown values from template series name
// name[0] is event/formula name, name[1+] are breakdown values
const breakdownValues =
templateSerie.name.length > 1 ? templateSerie.name.slice(1) : [];
const formulaName =
breakdownValues.length > 0
? [formula.displayName || formula.formula, ...breakdownValues]
: [formula.displayName || formula.formula];
const formulaSeries: ConcreteSeries = {
id: `formula-${formula.id ?? formulaIndex}-${breakdownSignature || 'default'}`,
definitionId:
formula.id ?? alphabetIds[formulaIndex] ?? `formula-${formulaIndex}`,
definitionIndex: formulaIndex,
name: formulaName,
context: {
filters: templateSerie.context.filters,
breakdownValue: templateSerie.context.breakdownValue,
breakdowns: templateSerie.context.breakdowns,
},
data: formulaData,
definition: formula,
};
results.push(formulaSeries);
}
});
return results;
}

View File

@@ -0,0 +1,151 @@
import type { ISerieDataItem } from '@openpanel/common';
import { groupByLabels } from '@openpanel/common';
import { alphabetIds } from '@openpanel/constants';
import type { IGetChartDataInput } from '@openpanel/validation';
import { chQuery } from '../clickhouse/client';
import { getChartSql } from '../services/chart.service';
import type { ConcreteSeries, Plan } from './types';
/**
* Fetch data for all event series in the plan
* This handles breakdown expansion automatically via groupByLabels
*/
export async function fetch(plan: Plan): Promise<ConcreteSeries[]> {
const results: ConcreteSeries[] = [];
// Process each event definition
for (let i = 0; i < plan.definitions.length; i++) {
const definition = plan.definitions[i]!;
if (definition.type !== 'event') {
// Skip formulas - they'll be handled in compute stage
continue;
}
const event = definition as typeof definition & { type: 'event' };
// Find the corresponding concrete series placeholder
const placeholder = plan.concreteSeries.find(
(cs) => cs.definitionId === definition.id,
);
if (!placeholder) {
continue;
}
// Build query input
const queryInput: IGetChartDataInput = {
event: {
id: event.id,
name: event.name,
segment: event.segment,
filters: event.filters,
displayName: event.displayName,
property: event.property,
},
projectId: plan.input.projectId,
startDate: plan.input.startDate,
endDate: plan.input.endDate,
breakdowns: plan.input.breakdowns,
interval: plan.input.interval,
chartType: plan.input.chartType,
metric: plan.input.metric,
previous: plan.input.previous ?? false,
limit: plan.input.limit,
offset: plan.input.offset,
criteria: plan.input.criteria,
funnelGroup: plan.input.funnelGroup,
funnelWindow: plan.input.funnelWindow,
};
// Execute query
let queryResult = await chQuery<ISerieDataItem>(
getChartSql({ ...queryInput, timezone: plan.timezone }),
{
session_timezone: plan.timezone,
},
);
// Fallback: if no results with breakdowns, try without breakdowns
if (queryResult.length === 0 && plan.input.breakdowns.length > 0) {
queryResult = await chQuery<ISerieDataItem>(
getChartSql({
...queryInput,
breakdowns: [],
timezone: plan.timezone,
}),
{
session_timezone: plan.timezone,
},
);
}
// Group by labels (handles breakdown expansion)
const groupedSeries = groupByLabels(queryResult);
// Create concrete series for each grouped result
groupedSeries.forEach((grouped) => {
// Extract breakdown value from name array
// If breakdowns exist, name[0] is event name, name[1+] are breakdown values
const breakdownValue =
plan.input.breakdowns.length > 0 && grouped.name.length > 1
? grouped.name.slice(1).join(' - ')
: undefined;
// Build breakdowns object: { country: 'SE', path: '/ewoqmepwq' }
const breakdowns: Record<string, string> | undefined =
plan.input.breakdowns.length > 0 && grouped.name.length > 1
? {}
: undefined;
if (breakdowns) {
plan.input.breakdowns.forEach((breakdown, idx) => {
const breakdownNamePart = grouped.name[idx + 1];
if (breakdownNamePart) {
breakdowns[breakdown.name] = breakdownNamePart;
}
});
}
// Build filters including breakdown value
const filters = [...event.filters];
if (breakdownValue && plan.input.breakdowns.length > 0) {
// Add breakdown filter
plan.input.breakdowns.forEach((breakdown, idx) => {
const breakdownNamePart = grouped.name[idx + 1];
if (breakdownNamePart) {
filters.push({
id: `breakdown-${idx}`,
name: breakdown.name,
operator: 'is',
value: [breakdownNamePart],
});
}
});
}
const concrete: ConcreteSeries = {
id: `${placeholder.id}-${grouped.name.join('-')}`,
definitionId: definition.id ?? alphabetIds[i] ?? `series-${i}`,
definitionIndex: i,
name: grouped.name,
context: {
event: event.name,
filters,
breakdownValue,
breakdowns,
},
data: grouped.data.map((item) => ({
date: item.date,
count: item.count,
total_count: item.total_count,
})),
definition,
};
results.push(concrete);
});
}
return results;
}

View File

@@ -0,0 +1,145 @@
import {
average,
getPreviousMetric,
max,
min,
round,
slug,
sum,
} from '@openpanel/common';
import { alphabetIds } from '@openpanel/constants';
import type { FinalChart } from '@openpanel/validation';
import type { ConcreteSeries } from './types';
/**
* Format concrete series into FinalChart format (backward compatible)
* TODO: Migrate frontend to use cleaner ChartResponse format
*/
export function format(
concreteSeries: ConcreteSeries[],
definitions: Array<{
id?: string;
type: 'event' | 'formula';
displayName?: string;
formula?: string;
name?: string;
}>,
includeAlphaIds: boolean,
previousSeries: ConcreteSeries[] | null = null,
limit: number | undefined = undefined,
): FinalChart {
const series = concreteSeries.map((cs) => {
// Find definition for this series
const definition = definitions[cs.definitionIndex];
const alphaId = includeAlphaIds
? alphabetIds[cs.definitionIndex]
: undefined;
// Build display name with optional alpha ID
let displayName: string[];
// Replace the first name (which is the event name) with the display name if it exists
const names = cs.name.slice(0);
if (cs.definition.displayName) {
names.splice(0, 1, cs.definition.displayName);
}
// Add the alpha ID to the first name if it exists
if (alphaId) {
displayName = [`(${alphaId}) ${names[0]}`, ...names.slice(1)];
} else {
displayName = names;
}
// Calculate metrics for this series
const counts = cs.data.map((d) => d.count);
const metrics = {
sum: sum(counts),
average: round(average(counts), 2),
min: min(counts),
max: max(counts),
count: cs.data.find((item) => !!item.total_count)?.total_count,
};
// Build event object for compatibility
const eventName =
definition?.type === 'formula'
? definition.displayName || definition.formula || 'Formula'
: definition?.name || cs.context.event || 'unknown';
// Find matching previous series
const previousSerie = previousSeries?.find(
(ps) =>
ps.definitionIndex === cs.definitionIndex &&
ps.name.slice(1).join(':::') === cs.name.slice(1).join(':::'),
);
return {
id: slug(cs.id),
names: displayName,
// TODO: Do we need this now?
event: {
id: definition?.id,
name: eventName,
breakdowns: cs.context.breakdowns,
},
metrics: {
...metrics,
...(previousSerie
? {
previous: {
sum: getPreviousMetric(
metrics.sum,
sum(previousSerie.data.map((d) => d.count)),
),
average: getPreviousMetric(
metrics.average,
round(average(previousSerie.data.map((d) => d.count)), 2),
),
min: getPreviousMetric(
metrics.min,
min(previousSerie.data.map((d) => d.count)),
),
max: getPreviousMetric(
metrics.max,
max(previousSerie.data.map((d) => d.count)),
),
count: getPreviousMetric(
metrics.count ?? 0,
previousSerie.data.find((item) => !!item.total_count)
?.total_count ?? null,
),
},
}
: {}),
},
data: cs.data.map((item, index) => ({
date: item.date,
count: item.count,
previous: previousSerie?.data[index]
? getPreviousMetric(
item.count,
previousSerie.data[index]?.count ?? null,
)
: undefined,
})),
};
});
// Sort series by sum (biggest first)
series.sort((a, b) => b.metrics.sum - a.metrics.sum);
// Calculate global metrics
const allValues = concreteSeries.flatMap((cs) => cs.data.map((d) => d.count));
const globalMetrics = {
sum: sum(allValues),
average: round(average(allValues), 2),
min: min(allValues),
max: max(allValues),
count: undefined as number | undefined,
};
return {
series: limit ? series.slice(0, limit) : series,
metrics: globalMetrics,
};
}

View File

@@ -0,0 +1,75 @@
import { getPreviousMetric } from '@openpanel/common';
import type { FinalChart, IChartInput } from '@openpanel/validation';
import { getChartPrevStartEndDate } from '../services/chart.service';
import {
getOrganizationSubscriptionChartEndDate,
getSettingsForProject,
} from '../services/organization.service';
import { compute } from './compute';
import { fetch } from './fetch';
import { format } from './format';
import { normalize } from './normalize';
import { plan } from './plan';
import type { ConcreteSeries } from './types';
/**
* Chart Engine - Main entry point
* Executes the pipeline: normalize -> plan -> fetch -> compute -> format
*/
export async function executeChart(input: IChartInput): Promise<FinalChart> {
// Stage 1: Normalize input
const normalized = await normalize(input);
// Handle subscription end date limit
const endDate = await getOrganizationSubscriptionChartEndDate(
input.projectId,
normalized.endDate,
);
if (endDate) {
normalized.endDate = endDate;
}
// Stage 2: Create execution plan
const executionPlan = await plan(normalized);
// Stage 3: Fetch data for event series (current period)
const fetchedSeries = await fetch(executionPlan);
// Stage 4: Compute formula series
const computedSeries = compute(fetchedSeries, executionPlan.definitions);
// Stage 5: Fetch previous period if requested
let previousSeries: ConcreteSeries[] | null = null;
if (input.previous) {
const currentPeriod = {
startDate: normalized.startDate,
endDate: normalized.endDate,
};
const previousPeriod = getChartPrevStartEndDate(currentPeriod);
const previousPlan = await plan({
...normalized,
...previousPeriod,
});
const previousFetched = await fetch(previousPlan);
previousSeries = compute(previousFetched, previousPlan.definitions);
}
// Stage 6: Format final output with previous period data
const includeAlphaIds = executionPlan.definitions.length > 1;
const response = format(
computedSeries,
executionPlan.definitions,
includeAlphaIds,
previousSeries,
);
return response;
}
// Export as ChartEngine for backward compatibility
export const ChartEngine = {
execute: executeChart,
};

View File

@@ -0,0 +1,66 @@
import { alphabetIds } from '@openpanel/constants';
import type {
IChartEvent,
IChartEventItem,
IChartInput,
IChartInputWithDates,
} from '@openpanel/validation';
import { getChartStartEndDate } from '../services/chart.service';
import { getSettingsForProject } from '../services/organization.service';
import type { SeriesDefinition } from './types';
export type NormalizedInput = Awaited<ReturnType<typeof normalize>>;
/**
* Normalize a chart input into a clean structure with dates and normalized series
*/
export async function normalize(
input: IChartInput,
): Promise<IChartInputWithDates & { series: SeriesDefinition[] }> {
const { timezone } = await getSettingsForProject(input.projectId);
const { startDate, endDate } = getChartStartEndDate(
{
range: input.range,
startDate: input.startDate ?? undefined,
endDate: input.endDate ?? undefined,
},
timezone,
);
// Get series from input (handles both 'series' and 'events' fields)
// The schema preprocessing should have already converted 'events' to 'series', but handle both for safety
const rawSeries = (input as any).series ?? (input as any).events ?? [];
// Normalize each series item
const normalizedSeries: SeriesDefinition[] = rawSeries.map(
(item: any, index: number) => {
// If item already has type field, it's the new format
if (item && typeof item === 'object' && 'type' in item) {
return {
...item,
id: item.id ?? alphabetIds[index] ?? `series-${index}`,
} as SeriesDefinition;
}
// Old format without type field - assume it's an event
const event = item as Partial<IChartEvent>;
return {
type: 'event',
id: event.id ?? alphabetIds[index] ?? `series-${index}`,
name: event.name || 'unknown_event',
segment: event.segment ?? 'event',
filters: event.filters ?? [],
displayName: event.displayName,
property: event.property,
} as SeriesDefinition;
},
);
return {
...input,
series: normalizedSeries,
startDate,
endDate,
};
}

View File

@@ -0,0 +1,50 @@
import { slug } from '@openpanel/common';
import { alphabetIds } from '@openpanel/constants';
import type { IChartEventItem } from '@openpanel/validation';
import { getSettingsForProject } from '../services/organization.service';
import type { NormalizedInput } from './normalize';
import type { ConcreteSeries, Plan } from './types';
/**
* Create an execution plan from normalized input
* This sets up ConcreteSeries placeholders - actual breakdown expansion happens during fetch
*/
export async function plan(normalized: NormalizedInput): Promise<Plan> {
const { timezone } = await getSettingsForProject(normalized.projectId);
const concreteSeries: ConcreteSeries[] = [];
// Create concrete series placeholders for each definition
normalized.series.forEach((definition, index) => {
if (definition.type === 'event') {
const event = definition as IChartEventItem & { type: 'event' };
// For events, create a placeholder
// If breakdowns exist, fetch will return multiple series (one per breakdown value)
// If no breakdowns, fetch will return one series
const concrete: ConcreteSeries = {
id: `${slug(event.name)}-${event.id ?? index}`,
definitionId: event.id ?? alphabetIds[index] ?? `series-${index}`,
definitionIndex: index,
name: [event.displayName || event.name],
context: {
event: event.name,
filters: [...event.filters],
},
data: [], // Will be populated by fetch stage
definition,
};
concreteSeries.push(concrete);
} else {
// For formulas, we'll create placeholders during compute stage
// Formulas depend on event series, so we skip them here
}
});
return {
concreteSeries,
definitions: normalized.series,
input: normalized,
timezone,
};
}

View File

@@ -0,0 +1,85 @@
import type {
IChartBreakdown,
IChartEvent,
IChartEventFilter,
IChartEventItem,
IChartFormula,
IChartInput,
IChartInputWithDates,
} from '@openpanel/validation';
/**
* Series Definition - The input representation of what the user wants
* This is what comes from the frontend (events or formulas)
*/
export type SeriesDefinition = IChartEventItem;
/**
* Concrete Series - A resolved series that will be displayed as a line/bar on the chart
* When breakdowns exist, one SeriesDefinition can expand into multiple ConcreteSeries
*/
export type ConcreteSeries = {
id: string;
definitionId: string; // ID of the SeriesDefinition this came from
definitionIndex: number; // Index in the original series array (for A, B, C references)
name: string[]; // Display name parts: ["Session Start", "Chrome"] or ["Formula 1"]
// Context for Drill-down / Profiles
// This contains everything needed to query 'who are these users?'
context: {
event?: string; // Event name (if this is an event series)
filters: IChartEventFilter[]; // All filters including breakdown value
breakdownValue?: string; // The breakdown value for this concrete series (deprecated, use breakdowns instead)
breakdowns?: Record<string, string>; // Breakdown keys and values: { country: 'SE', path: '/ewoqmepwq' }
};
// Data points for this series
data: Array<{
date: string;
count: number;
total_count?: number;
}>;
// The original definition (event or formula)
definition: SeriesDefinition;
};
/**
* Plan - The execution plan after normalization and expansion
*/
export type Plan = {
concreteSeries: ConcreteSeries[];
definitions: SeriesDefinition[];
input: IChartInputWithDates;
timezone: string;
};
/**
* Chart Response - The final output format
*/
export type ChartResponse = {
series: Array<{
id: string;
name: string[];
data: Array<{
date: string;
value: number;
previous?: number;
}>;
summary: {
total: number;
average: number;
min: number;
max: number;
count?: number;
};
context?: ConcreteSeries['context']; // Include context for drill-down
}>;
summary: {
total: number;
average: number;
min: number;
max: number;
};
};

View File

@@ -155,7 +155,8 @@ export function getChartSql({
} }
breakdowns.forEach((breakdown, index) => { breakdowns.forEach((breakdown, index) => {
const key = `label_${index}`; // Breakdowns start at label_1 (label_0 is reserved for event name)
const key = `label_${index + 1}`;
sb.select[key] = `${getSelectPropertyKey(breakdown.name)} as ${key}`; sb.select[key] = `${getSelectPropertyKey(breakdown.name)} as ${key}`;
sb.groupBy[key] = `${key}`; sb.groupBy[key] = `${key}`;
}); });
@@ -175,8 +176,8 @@ export function getChartSql({
if (event.segment === 'property_sum' && event.property) { if (event.segment === 'property_sum' && event.property) {
if (event.property === 'revenue') { if (event.property === 'revenue') {
sb.select.count = `sum(revenue) as count`; sb.select.count = 'sum(revenue) as count';
sb.where.property = `revenue > 0`; sb.where.property = 'revenue > 0';
} else { } else {
sb.select.count = `sum(toFloat64(${getSelectPropertyKey(event.property)})) as count`; sb.select.count = `sum(toFloat64(${getSelectPropertyKey(event.property)})) as count`;
sb.where.property = `${getSelectPropertyKey(event.property)} IS NOT NULL AND notEmpty(${getSelectPropertyKey(event.property)})`; sb.where.property = `${getSelectPropertyKey(event.property)} IS NOT NULL AND notEmpty(${getSelectPropertyKey(event.property)})`;
@@ -185,8 +186,8 @@ export function getChartSql({
if (event.segment === 'property_average' && event.property) { if (event.segment === 'property_average' && event.property) {
if (event.property === 'revenue') { if (event.property === 'revenue') {
sb.select.count = `avg(revenue) as count`; sb.select.count = 'avg(revenue) as count';
sb.where.property = `revenue > 0`; sb.where.property = 'revenue > 0';
} else { } else {
sb.select.count = `avg(toFloat64(${getSelectPropertyKey(event.property)})) as count`; sb.select.count = `avg(toFloat64(${getSelectPropertyKey(event.property)})) as count`;
sb.where.property = `${getSelectPropertyKey(event.property)} IS NOT NULL AND notEmpty(${getSelectPropertyKey(event.property)})`; sb.where.property = `${getSelectPropertyKey(event.property)} IS NOT NULL AND notEmpty(${getSelectPropertyKey(event.property)})`;
@@ -195,8 +196,8 @@ export function getChartSql({
if (event.segment === 'property_max' && event.property) { if (event.segment === 'property_max' && event.property) {
if (event.property === 'revenue') { if (event.property === 'revenue') {
sb.select.count = `max(revenue) as count`; sb.select.count = 'max(revenue) as count';
sb.where.property = `revenue > 0`; sb.where.property = 'revenue > 0';
} else { } else {
sb.select.count = `max(toFloat64(${getSelectPropertyKey(event.property)})) as count`; sb.select.count = `max(toFloat64(${getSelectPropertyKey(event.property)})) as count`;
sb.where.property = `${getSelectPropertyKey(event.property)} IS NOT NULL AND notEmpty(${getSelectPropertyKey(event.property)})`; sb.where.property = `${getSelectPropertyKey(event.property)} IS NOT NULL AND notEmpty(${getSelectPropertyKey(event.property)})`;
@@ -205,8 +206,8 @@ export function getChartSql({
if (event.segment === 'property_min' && event.property) { if (event.segment === 'property_min' && event.property) {
if (event.property === 'revenue') { if (event.property === 'revenue') {
sb.select.count = `min(revenue) as count`; sb.select.count = 'min(revenue) as count';
sb.where.property = `revenue > 0`; sb.where.property = 'revenue > 0';
} else { } else {
sb.select.count = `min(toFloat64(${getSelectPropertyKey(event.property)})) as count`; sb.select.count = `min(toFloat64(${getSelectPropertyKey(event.property)})) as count`;
sb.where.property = `${getSelectPropertyKey(event.property)} IS NOT NULL AND notEmpty(${getSelectPropertyKey(event.property)})`; sb.where.property = `${getSelectPropertyKey(event.property)} IS NOT NULL AND notEmpty(${getSelectPropertyKey(event.property)})`;
@@ -230,8 +231,52 @@ export function getChartSql({
return sql; return sql;
} }
// Add total unique count for user segment using a scalar subquery // Build total_count calculation that accounts for breakdowns
if (event.segment === 'user') { // When breakdowns exist, we need to calculate total_count per breakdown group
if (breakdowns.length > 0) {
// Create a subquery that calculates total_count per breakdown group (without date grouping)
// Then reference it in the main query via JOIN
const breakdownSelects = breakdowns
.map((breakdown, index) => {
const key = `label_${index + 1}`;
const breakdownExpr = getSelectPropertyKey(breakdown.name);
return `${breakdownExpr} as ${key}`;
})
.join(', ');
// GROUP BY needs to use the actual expressions, not aliases
const breakdownGroupByExprs = breakdowns
.map((breakdown) => getSelectPropertyKey(breakdown.name))
.join(', ');
// Build the total_count subquery grouped only by breakdowns (no date)
// Extract the count expression without the alias (remove "as count")
const countExpression = sb.select.count.replace(/\s+as\s+count$/i, '');
const totalCountSubquery = `(
SELECT
${breakdownSelects},
${countExpression} as total_count
FROM ${sb.from}
${getJoins()}
${getWhere()}
GROUP BY ${breakdownGroupByExprs}
) as total_counts`;
// Join the total_counts subquery to get total_count per breakdown
// Match on the breakdown column values
const joinConditions = breakdowns
.map((_, index) => {
const outerKey = `label_${index + 1}`;
return `${outerKey} = total_counts.label_${index + 1}`;
})
.join(' AND ');
sb.joins.total_counts = `LEFT JOIN ${totalCountSubquery} ON ${joinConditions}`;
// Use any() aggregate since total_count is the same for all rows in a breakdown group
sb.select.total_unique_count =
'any(total_counts.total_count) as total_count';
} else {
// No breakdowns - use a simple subquery for total count
const totalUniqueSubquery = `( const totalUniqueSubquery = `(
SELECT ${sb.select.count} SELECT ${sb.select.count}
FROM ${sb.from} FROM ${sb.from}
@@ -509,12 +554,11 @@ export function getChartStartEndDate(
}: Pick<IChartInput, 'endDate' | 'startDate' | 'range'>, }: Pick<IChartInput, 'endDate' | 'startDate' | 'range'>,
timezone: string, timezone: string,
) { ) {
const ranges = getDatesFromRange(range, timezone);
if (startDate && endDate) { if (startDate && endDate) {
return { startDate: startDate, endDate: endDate }; return { startDate: startDate, endDate: endDate };
} }
const ranges = getDatesFromRange(range, timezone);
if (!startDate && endDate) { if (!startDate && endDate) {
return { startDate: ranges.startDate, endDate: endDate }; return { startDate: ranges.startDate, endDate: endDate };
} }

View File

@@ -1,5 +1,5 @@
import { NOT_SET_VALUE } from '@openpanel/constants'; import { NOT_SET_VALUE } from '@openpanel/constants';
import type { IChartInput } from '@openpanel/validation'; import type { IChartEvent, IChartInput } from '@openpanel/validation';
import { omit } from 'ramda'; import { omit } from 'ramda';
import { TABLE_NAMES, ch } from '../clickhouse/client'; import { TABLE_NAMES, ch } from '../clickhouse/client';
import { clix } from '../clickhouse/query-builder'; import { clix } from '../clickhouse/query-builder';
@@ -7,6 +7,7 @@ import {
getEventFiltersWhereClause, getEventFiltersWhereClause,
getSelectPropertyKey, getSelectPropertyKey,
} from './chart.service'; } from './chart.service';
import { onlyReportEvents } from './reports.service';
export class ConversionService { export class ConversionService {
constructor(private client: typeof ch) {} constructor(private client: typeof ch) {}
@@ -17,8 +18,9 @@ export class ConversionService {
endDate, endDate,
funnelGroup, funnelGroup,
funnelWindow = 24, funnelWindow = 24,
events, series,
breakdowns = [], breakdowns = [],
limit,
interval, interval,
timezone, timezone,
}: Omit<IChartInput, 'range' | 'previous' | 'metric' | 'chartType'> & { }: Omit<IChartInput, 'range' | 'previous' | 'metric' | 'chartType'> & {
@@ -30,6 +32,8 @@ export class ConversionService {
); );
const breakdownGroupBy = breakdowns.map((b, index) => `b_${index}`); const breakdownGroupBy = breakdowns.map((b, index) => `b_${index}`);
const events = onlyReportEvents(series);
if (events.length !== 2) { if (events.length !== 2) {
throw new Error('events must be an array of two events'); throw new Error('events must be an array of two events');
} }
@@ -111,7 +115,8 @@ export class ConversionService {
} }
const results = await query.execute(); const results = await query.execute();
return this.toSeries(results, breakdowns).map((serie, serieIndex) => { return this.toSeries(results, breakdowns, limit).map(
(serie, serieIndex) => {
return { return {
...serie, ...serie,
data: serie.data.map((d, index) => ({ data: serie.data.map((d, index) => ({
@@ -122,7 +127,8 @@ export class ConversionService {
serie: omit(['data'], serie), serie: omit(['data'], serie),
})), })),
}; };
}); },
);
} }
private toSeries( private toSeries(
@@ -134,6 +140,7 @@ export class ConversionService {
[key: string]: string | number; [key: string]: string | number;
}[], }[],
breakdowns: { name: string }[] = [], breakdowns: { name: string }[] = [],
limit: number | undefined = undefined,
) { ) {
if (!breakdowns.length) { if (!breakdowns.length) {
return [ return [
@@ -153,6 +160,10 @@ export class ConversionService {
// Group by breakdown values // Group by breakdown values
const series = data.reduce( const series = data.reduce(
(acc, d) => { (acc, d) => {
if (limit && Object.keys(acc).length >= limit) {
return acc;
}
const key = const key =
breakdowns.map((b, index) => d[`b_${index}`]).join('|') || breakdowns.map((b, index) => d[`b_${index}`]).join('|') ||
NOT_SET_VALUE; NOT_SET_VALUE;

View File

@@ -1,5 +1,9 @@
import { ifNaN } from '@openpanel/common'; import { ifNaN } from '@openpanel/common';
import type { IChartEvent, IChartInput } from '@openpanel/validation'; import type {
IChartEvent,
IChartEventItem,
IChartInput,
} from '@openpanel/validation';
import { last, reverse, uniq } from 'ramda'; import { last, reverse, uniq } from 'ramda';
import sqlstring from 'sqlstring'; import sqlstring from 'sqlstring';
import { ch } from '../clickhouse/client'; import { ch } from '../clickhouse/client';
@@ -10,17 +14,18 @@ import {
getEventFiltersWhereClause, getEventFiltersWhereClause,
getSelectPropertyKey, getSelectPropertyKey,
} from './chart.service'; } from './chart.service';
import { onlyReportEvents } from './reports.service';
export class FunnelService { export class FunnelService {
constructor(private client: typeof ch) {} constructor(private client: typeof ch) {}
private getFunnelGroup(group?: string) { getFunnelGroup(group?: string): [string, string] {
return group === 'profile_id' return group === 'profile_id'
? [`COALESCE(nullIf(s.pid, ''), profile_id)`, 'profile_id'] ? [`COALESCE(nullIf(s.pid, ''), profile_id)`, 'profile_id']
: ['session_id', 'session_id']; : ['session_id', 'session_id'];
} }
private getFunnelConditions(events: IChartEvent[]) { getFunnelConditions(events: IChartEvent[] = []): string[] {
return events.map((event) => { return events.map((event) => {
const { sb, getWhere } = createSqlBuilder(); const { sb, getWhere } = createSqlBuilder();
sb.where = getEventFiltersWhereClause(event.filters); sb.where = getEventFiltersWhereClause(event.filters);
@@ -29,6 +34,70 @@ export class FunnelService {
}); });
} }
buildFunnelCte({
projectId,
startDate,
endDate,
eventSeries,
funnelWindowMilliseconds,
group,
timezone,
additionalSelects = [],
additionalGroupBy = [],
}: {
projectId: string;
startDate: string;
endDate: string;
eventSeries: IChartEvent[];
funnelWindowMilliseconds: number;
group: [string, string];
timezone: string;
additionalSelects?: string[];
additionalGroupBy?: string[];
}) {
const funnels = this.getFunnelConditions(eventSeries);
return clix(this.client, timezone)
.select([
`${group[0]} AS ${group[1]}`,
...additionalSelects,
`windowFunnel(${funnelWindowMilliseconds}, 'strict_increase')(toUInt64(toUnixTimestamp64Milli(created_at)), ${funnels.join(', ')}) AS level`,
])
.from(TABLE_NAMES.events, false)
.where('project_id', '=', projectId)
.where('created_at', 'BETWEEN', [
clix.datetime(startDate, 'toDateTime'),
clix.datetime(endDate, 'toDateTime'),
])
.where(
'name',
'IN',
eventSeries.map((e) => e.name),
)
.groupBy([group[1], ...additionalGroupBy]);
}
buildSessionsCte({
projectId,
startDate,
endDate,
timezone,
}: {
projectId: string;
startDate: string;
endDate: string;
timezone: string;
}) {
return clix(this.client, timezone)
.select(['profile_id as pid', 'id as sid'])
.from(TABLE_NAMES.sessions)
.where('project_id', '=', projectId)
.where('created_at', 'BETWEEN', [
clix.datetime(startDate, 'toDateTime'),
clix.datetime(endDate, 'toDateTime'),
]);
}
private fillFunnel( private fillFunnel(
funnel: { level: number; count: number }[], funnel: { level: number; count: number }[],
steps: number, steps: number,
@@ -57,6 +126,7 @@ export class FunnelService {
toSeries( toSeries(
funnel: { level: number; count: number; [key: string]: any }[], funnel: { level: number; count: number; [key: string]: any }[],
breakdowns: { name: string }[] = [], breakdowns: { name: string }[] = [],
limit: number | undefined = undefined,
) { ) {
if (!breakdowns.length) { if (!breakdowns.length) {
return [ return [
@@ -72,6 +142,10 @@ export class FunnelService {
// Group by breakdown values // Group by breakdown values
const series = funnel.reduce( const series = funnel.reduce(
(acc, f) => { (acc, f) => {
if (limit && Object.keys(acc).length >= limit) {
return acc;
}
const key = breakdowns.map((b, index) => f[`b_${index}`]).join('|'); const key = breakdowns.map((b, index) => f[`b_${index}`]).join('|');
if (!acc[key]) { if (!acc[key]) {
acc[key] = []; acc[key] = [];
@@ -110,51 +184,49 @@ export class FunnelService {
projectId, projectId,
startDate, startDate,
endDate, endDate,
events, series,
funnelWindow = 24, funnelWindow = 24,
funnelGroup, funnelGroup,
breakdowns = [], breakdowns = [],
limit,
timezone = 'UTC', timezone = 'UTC',
}: IChartInput & { timezone: string }) { }: IChartInput & { timezone: string; events?: IChartEvent[] }) {
if (!startDate || !endDate) { if (!startDate || !endDate) {
throw new Error('startDate and endDate are required'); throw new Error('startDate and endDate are required');
} }
if (events.length === 0) { const eventSeries = onlyReportEvents(series);
if (eventSeries.length === 0) {
throw new Error('events are required'); throw new Error('events are required');
} }
const funnelWindowSeconds = funnelWindow * 3600; const funnelWindowSeconds = funnelWindow * 3600;
const funnelWindowMilliseconds = funnelWindowSeconds * 1000; const funnelWindowMilliseconds = funnelWindowSeconds * 1000;
const group = this.getFunnelGroup(funnelGroup); const group = this.getFunnelGroup(funnelGroup);
const funnels = this.getFunnelConditions(events); const profileFilters = this.getProfileFilters(eventSeries);
const profileFilters = this.getProfileFilters(events);
const anyFilterOnProfile = profileFilters.length > 0; const anyFilterOnProfile = profileFilters.length > 0;
const anyBreakdownOnProfile = breakdowns.some((b) => const anyBreakdownOnProfile = breakdowns.some((b) =>
b.name.startsWith('profile.'), b.name.startsWith('profile.'),
); );
// Create the funnel CTE // Create the funnel CTE
const funnelCte = clix(this.client, timezone) const breakdownSelects = breakdowns.map(
.select([
`${group[0]} AS ${group[1]}`,
...breakdowns.map(
(b, index) => `${getSelectPropertyKey(b.name)} as b_${index}`, (b, index) => `${getSelectPropertyKey(b.name)} as b_${index}`,
), );
`windowFunnel(${funnelWindowMilliseconds}, 'strict_increase')(toUInt64(toUnixTimestamp64Milli(created_at)), ${funnels.join(', ')}) AS level`, const breakdownGroupBy = breakdowns.map((b, index) => `b_${index}`);
])
.from(TABLE_NAMES.events, false) const funnelCte = this.buildFunnelCte({
.where('project_id', '=', projectId) projectId,
.where('created_at', 'BETWEEN', [ startDate,
clix.datetime(startDate, 'toDateTime'), endDate,
clix.datetime(endDate, 'toDateTime'), eventSeries,
]) funnelWindowMilliseconds,
.where( group,
'name', timezone,
'IN', additionalSelects: breakdownSelects,
events.map((e) => e.name), additionalGroupBy: breakdownGroupBy,
) });
.groupBy([group[1], ...breakdowns.map((b, index) => `b_${index}`)]);
if (anyFilterOnProfile || anyBreakdownOnProfile) { if (anyFilterOnProfile || anyBreakdownOnProfile) {
funnelCte.leftJoin( funnelCte.leftJoin(
@@ -167,15 +239,12 @@ export class FunnelService {
// Create the sessions CTE if needed // Create the sessions CTE if needed
const sessionsCte = const sessionsCte =
group[0] !== 'session_id' group[0] !== 'session_id'
? clix(this.client, timezone) ? this.buildSessionsCte({
// Important to have unique field names to avoid ambiguity in the main query projectId,
.select(['profile_id as pid', 'id as sid']) startDate,
.from(TABLE_NAMES.sessions) endDate,
.where('project_id', '=', projectId) timezone,
.where('created_at', 'BETWEEN', [ })
clix.datetime(startDate, 'toDateTime'),
clix.datetime(endDate, 'toDateTime'),
])
: null; : null;
// Base funnel query with CTEs // Base funnel query with CTEs
@@ -204,11 +273,11 @@ export class FunnelService {
.orderBy('level', 'DESC'); .orderBy('level', 'DESC');
const funnelData = await funnelQuery.execute(); const funnelData = await funnelQuery.execute();
const funnelSeries = this.toSeries(funnelData, breakdowns); const funnelSeries = this.toSeries(funnelData, breakdowns, limit);
return funnelSeries return funnelSeries
.map((data) => { .map((data) => {
const maxLevel = events.length; const maxLevel = eventSeries.length;
const filledFunnelRes = this.fillFunnel( const filledFunnelRes = this.fillFunnel(
data.map((d) => ({ level: d.level, count: d.count })), data.map((d) => ({ level: d.level, count: d.count })),
maxLevel, maxLevel,
@@ -220,7 +289,7 @@ export class FunnelService {
(acc, item, index, list) => { (acc, item, index, list) => {
const prev = list[index - 1] ?? { count: totalSessions }; const prev = list[index - 1] ?? { count: totalSessions };
const next = list[index + 1]; const next = list[index + 1];
const event = events[item.level - 1]!; const event = eventSeries[item.level - 1]!;
return [ return [
...acc, ...acc,
{ {

View File

@@ -5,19 +5,25 @@ import {
} from '@openpanel/constants'; } from '@openpanel/constants';
import type { import type {
IChartBreakdown, IChartBreakdown,
IChartEvent,
IChartEventFilter, IChartEventFilter,
IChartEventItem,
IChartLineType, IChartLineType,
IChartProps, IChartProps,
IChartRange, IChartRange,
ICriteria, ICriteria,
} from '@openpanel/validation'; } from '@openpanel/validation';
import { db } from '../prisma-client';
import type { Report as DbReport, ReportLayout } from '../prisma-client'; import type { Report as DbReport, ReportLayout } from '../prisma-client';
import { db } from '../prisma-client';
export type IServiceReport = Awaited<ReturnType<typeof getReportById>>; export type IServiceReport = Awaited<ReturnType<typeof getReportById>>;
export const onlyReportEvents = (
series: NonNullable<IServiceReport>['series'],
) => {
return series.filter((item) => item.type === 'event');
};
export function transformFilter( export function transformFilter(
filter: Partial<IChartEventFilter>, filter: Partial<IChartEventFilter>,
index: number, index: number,
@@ -31,17 +37,29 @@ export function transformFilter(
}; };
} }
export function transformReportEvent( export function transformReportEventItem(
event: Partial<IChartEvent>, item: IChartEventItem,
index: number, index: number,
): IChartEvent { ): IChartEventItem {
if (item.type === 'formula') {
// Transform formula
return { return {
segment: event.segment ?? 'event', type: 'formula',
filters: (event.filters ?? []).map(transformFilter), id: item.id ?? alphabetIds[index]!,
id: event.id ?? alphabetIds[index]!, formula: item.formula || '',
name: event.name || 'unknown_event', displayName: item.displayName,
displayName: event.displayName, };
property: event.property, }
// Transform event with type field
return {
type: 'event',
segment: item.segment ?? 'event',
filters: (item.filters ?? []).map(transformFilter),
id: item.id ?? alphabetIds[index]!,
name: item.name || 'unknown_event',
displayName: item.displayName,
property: item.property,
}; };
} }
@@ -51,7 +69,8 @@ export function transformReport(
return { return {
id: report.id, id: report.id,
projectId: report.projectId, projectId: report.projectId,
events: (report.events as IChartEvent[]).map(transformReportEvent), series:
(report.events as IChartEventItem[]).map(transformReportEventItem) ?? [],
breakdowns: report.breakdowns as IChartBreakdown[], breakdowns: report.breakdowns as IChartBreakdown[],
chartType: report.chartType, chartType: report.chartType,
lineType: (report.lineType as IChartLineType) ?? lineTypes.monotone, lineType: (report.lineType as IChartLineType) ?? lineTypes.monotone,

View File

@@ -1,544 +0,0 @@
import type { IChartEvent, IChartInput } from '@openpanel/validation';
import { describe, expect, it } from 'vitest';
import { withFormula } from './chart.helpers';
// Helper to create a test event
function createEvent(
id: string,
name: string,
displayName?: string,
): IChartEvent {
return {
id,
name,
displayName: displayName ?? '',
segment: 'event',
filters: [],
};
}
const createChartInput = (
rest: Pick<IChartInput, 'events' | 'formula'>,
): IChartInput => {
return {
metric: 'sum',
chartType: 'linear',
interval: 'day',
breakdowns: [],
projectId: '1',
startDate: '2025-01-01',
endDate: '2025-01-01',
range: '30d',
previous: false,
formula: '',
...rest,
};
};
// Helper to create a test series
function createSeries(
name: string[],
event: IChartEvent,
data: Array<{ date: string; count: number }>,
) {
return {
name,
event,
data: data.map((d) => ({ ...d, total_count: d.count })),
};
}
describe('withFormula', () => {
describe('edge cases', () => {
it('should return series unchanged when formula is empty', () => {
const events = [createEvent('evt1', 'event1')];
const series = [
createSeries(['event1'], events[0]!, [
{ date: '2025-01-01', count: 10 },
]),
];
const result = withFormula(
createChartInput({ formula: '', events }),
series,
);
expect(result).toEqual(series);
});
it('should return series unchanged when series is empty', () => {
const events = [createEvent('evt1', 'event1')];
const result = withFormula(
createChartInput({ formula: 'A*100', events }),
[],
);
expect(result).toEqual([]);
});
it('should return series unchanged when series has no data', () => {
const events = [createEvent('evt1', 'event1')];
const series = [{ name: ['event1'], event: events[0]!, data: [] }];
const result = withFormula(
createChartInput({ formula: 'A*100', events }),
series,
);
expect(result).toEqual(series);
});
});
describe('single event, no breakdown', () => {
it('should apply simple multiplication formula', () => {
const events = [createEvent('evt1', 'event1')];
const series = [
createSeries(['event1'], events[0]!, [
{ date: '2025-01-01', count: 10 },
{ date: '2025-01-02', count: 20 },
]),
];
const result = withFormula(
createChartInput({ formula: 'A*100', events }),
series,
);
expect(result).toHaveLength(1);
expect(result[0]?.data).toEqual([
{ date: '2025-01-01', count: 1000, total_count: 10 },
{ date: '2025-01-02', count: 2000, total_count: 20 },
]);
});
it('should apply addition formula', () => {
const events = [createEvent('evt1', 'event1')];
const series = [
createSeries(['event1'], events[0]!, [
{ date: '2025-01-01', count: 5 },
]),
];
const result = withFormula(
createChartInput({ formula: 'A+10', events }),
series,
);
expect(result[0]?.data[0]?.count).toBe(15);
});
it('should handle division formula', () => {
const events = [createEvent('evt1', 'event1')];
const series = [
createSeries(['event1'], events[0]!, [
{ date: '2025-01-01', count: 100 },
]),
];
const result = withFormula(
createChartInput({ formula: 'A/10', events }),
series,
);
expect(result[0]?.data[0]?.count).toBe(10);
});
it('should handle NaN and Infinity by returning 0', () => {
const events = [createEvent('evt1', 'event1')];
const series = [
createSeries(['event1'], events[0]!, [
{ date: '2025-01-01', count: 0 },
]),
];
const result = withFormula(
createChartInput({ formula: 'A/0', events }),
series,
);
expect(result[0]?.data[0]?.count).toBe(0);
});
});
describe('single event, with breakdown', () => {
it('should apply formula to each breakdown group', () => {
const events = [createEvent('evt1', 'screen_view')];
const series = [
createSeries(['iOS'], events[0]!, [{ date: '2025-01-01', count: 10 }]),
createSeries(['Android'], events[0]!, [
{ date: '2025-01-01', count: 20 },
]),
];
const result = withFormula(
createChartInput({ formula: 'A*100', events }),
series,
);
expect(result).toHaveLength(2);
expect(result[0]?.name).toEqual(['iOS']);
expect(result[0]?.data[0]?.count).toBe(1000);
expect(result[1]?.name).toEqual(['Android']);
expect(result[1]?.data[0]?.count).toBe(2000);
});
it('should handle multiple breakdown values', () => {
const events = [createEvent('evt1', 'screen_view')];
const series = [
createSeries(['iOS', 'US'], events[0]!, [
{ date: '2025-01-01', count: 10 },
]),
createSeries(['Android', 'US'], events[0]!, [
{ date: '2025-01-01', count: 20 },
]),
];
const result = withFormula(
createChartInput({ formula: 'A*2', events }),
series,
);
expect(result).toHaveLength(2);
expect(result[0]?.name).toEqual(['iOS', 'US']);
expect(result[0]?.data[0]?.count).toBe(20);
expect(result[1]?.name).toEqual(['Android', 'US']);
expect(result[1]?.data[0]?.count).toBe(40);
});
});
describe('multiple events, no breakdown', () => {
it('should combine two events with division formula', () => {
const events = [
createEvent('evt1', 'screen_view'),
createEvent('evt2', 'session_start'),
];
const series = [
createSeries(['screen_view'], events[0]!, [
{ date: '2025-01-01', count: 100 },
]),
createSeries(['session_start'], events[1]!, [
{ date: '2025-01-01', count: 50 },
]),
];
const result = withFormula(
createChartInput({ formula: 'A/B', events }),
series,
);
expect(result).toHaveLength(1);
expect(result[0]?.data[0]?.count).toBe(2);
});
it('should combine two events with addition formula', () => {
const events = [
createEvent('evt1', 'event1'),
createEvent('evt2', 'event2'),
];
const series = [
createSeries(['event1'], events[0]!, [
{ date: '2025-01-01', count: 10 },
]),
createSeries(['event2'], events[1]!, [
{ date: '2025-01-01', count: 20 },
]),
];
const result = withFormula(
createChartInput({ formula: 'A+B', events }),
series,
);
expect(result[0]?.data[0]?.count).toBe(30);
});
it('should handle three events', () => {
const events = [
createEvent('evt1', 'event1'),
createEvent('evt2', 'event2'),
createEvent('evt3', 'event3'),
];
const series = [
createSeries(['event1'], events[0]!, [
{ date: '2025-01-01', count: 10 },
]),
createSeries(['event2'], events[1]!, [
{ date: '2025-01-01', count: 20 },
]),
createSeries(['event3'], events[2]!, [
{ date: '2025-01-01', count: 30 },
]),
];
const result = withFormula(
createChartInput({ formula: 'A+B+C', events }),
series,
);
expect(result[0]?.data[0]?.count).toBe(60);
});
it('should handle missing data points with 0', () => {
const events = [
createEvent('evt1', 'event1'),
createEvent('evt2', 'event2'),
];
const series = [
createSeries(['event1'], events[0]!, [
{ date: '2025-01-01', count: 10 },
{ date: '2025-01-02', count: 20 },
]),
createSeries(['event2'], events[1]!, [
{ date: '2025-01-01', count: 5 },
// Missing 2025-01-02
]),
];
const result = withFormula(
createChartInput({ formula: 'A+B', events }),
series,
);
expect(result[0]?.data[0]?.count).toBe(15); // 10 + 5
expect(result[0]?.data[1]?.count).toBe(20); // 20 + 0 (missing)
});
});
describe('multiple events, with breakdown', () => {
it('should match series by breakdown values and apply formula', () => {
const events = [
createEvent('evt1', 'screen_view'),
createEvent('evt2', 'session_start'),
];
const series = [
// iOS breakdown
createSeries(['iOS'], events[0]!, [{ date: '2025-01-01', count: 100 }]),
createSeries(['iOS'], events[1]!, [{ date: '2025-01-01', count: 50 }]),
// Android breakdown
createSeries(['Android'], events[0]!, [
{ date: '2025-01-01', count: 200 },
]),
createSeries(['Android'], events[1]!, [
{ date: '2025-01-01', count: 100 },
]),
];
const result = withFormula(
createChartInput({ formula: 'A/B', events }),
series,
);
expect(result).toHaveLength(2);
// iOS: 100/50 = 2
expect(result[0]?.name).toEqual(['iOS']);
expect(result[0]?.data[0]?.count).toBe(2);
// Android: 200/100 = 2
expect(result[1]?.name).toEqual(['Android']);
expect(result[1]?.data[0]?.count).toBe(2);
});
it('should handle multiple breakdown values matching', () => {
const events = [
createEvent('evt1', 'screen_view'),
createEvent('evt2', 'session_start'),
];
const series = [
createSeries(['iOS', 'US'], events[0]!, [
{ date: '2025-01-01', count: 100 },
]),
createSeries(['iOS', 'US'], events[1]!, [
{ date: '2025-01-01', count: 50 },
]),
createSeries(['Android', 'US'], events[0]!, [
{ date: '2025-01-01', count: 200 },
]),
createSeries(['Android', 'US'], events[1]!, [
{ date: '2025-01-01', count: 100 },
]),
];
const result = withFormula(
createChartInput({ formula: 'A/B', events }),
series,
);
expect(result).toHaveLength(2);
expect(result[0]?.name).toEqual(['iOS', 'US']);
expect(result[0]?.data[0]?.count).toBe(2);
expect(result[1]?.name).toEqual(['Android', 'US']);
expect(result[1]?.data[0]?.count).toBe(2);
});
it('should handle different date ranges across breakdown groups', () => {
const events = [
createEvent('evt1', 'screen_view'),
createEvent('evt2', 'session_start'),
];
const series = [
createSeries(['iOS'], events[0]!, [
{ date: '2025-01-01', count: 100 },
{ date: '2025-01-02', count: 200 },
]),
createSeries(['iOS'], events[1]!, [
{ date: '2025-01-01', count: 50 },
{ date: '2025-01-02', count: 100 },
]),
createSeries(['Android'], events[0]!, [
{ date: '2025-01-01', count: 300 },
// Missing 2025-01-02
]),
createSeries(['Android'], events[1]!, [
{ date: '2025-01-01', count: 150 },
{ date: '2025-01-02', count: 200 },
]),
];
const result = withFormula(
createChartInput({ formula: 'A/B', events }),
series,
);
expect(result).toHaveLength(2);
// iOS group
expect(result[0]?.name).toEqual(['iOS']);
expect(result[0]?.data[0]?.count).toBe(2); // 100/50
expect(result[0]?.data[1]?.count).toBe(2); // 200/100
// Android group
expect(result[1]?.name).toEqual(['Android']);
expect(result[1]?.data[0]?.count).toBe(2); // 300/150
expect(result[1]?.data[1]?.count).toBe(0); // 0/200 = 0 (missing A)
});
});
describe('complex formulas', () => {
it('should handle complex expressions', () => {
const events = [
createEvent('evt1', 'event1'),
createEvent('evt2', 'event2'),
createEvent('evt3', 'event3'),
];
const series = [
createSeries(['event1'], events[0]!, [
{ date: '2025-01-01', count: 10 },
]),
createSeries(['event2'], events[1]!, [
{ date: '2025-01-01', count: 20 },
]),
createSeries(['event3'], events[2]!, [
{ date: '2025-01-01', count: 30 },
]),
];
const result = withFormula(
createChartInput({ formula: '(A+B)*C', events }),
series,
);
// (10+20)*30 = 900
expect(result[0]?.data[0]?.count).toBe(900);
});
it('should handle percentage calculations', () => {
const events = [
createEvent('evt1', 'screen_view'),
createEvent('evt2', 'session_start'),
];
const series = [
createSeries(['screen_view'], events[0]!, [
{ date: '2025-01-01', count: 75 },
]),
createSeries(['session_start'], events[1]!, [
{ date: '2025-01-01', count: 100 },
]),
];
const result = withFormula(
createChartInput({ formula: '(A/B)*100', events }),
series,
);
// (75/100)*100 = 75
expect(result[0]?.data[0]?.count).toBe(75);
});
});
describe('error handling', () => {
it('should handle invalid formulas gracefully', () => {
const events = [createEvent('evt1', 'event1')];
const series = [
createSeries(['event1'], events[0]!, [
{ date: '2025-01-01', count: 10 },
]),
];
const result = withFormula(
createChartInput({ formula: 'invalid formula', events }),
series,
);
// Should return 0 for invalid formulas
expect(result[0]?.data[0]?.count).toBe(0);
});
it('should handle division by zero', () => {
const events = [
createEvent('evt1', 'event1'),
createEvent('evt2', 'event2'),
];
const series = [
createSeries(['event1'], events[0]!, [
{ date: '2025-01-01', count: 10 },
]),
createSeries(['event2'], events[1]!, [
{ date: '2025-01-01', count: 0 },
]),
];
const result = withFormula(
createChartInput({ formula: 'A/B', events }),
series,
);
// Division by zero should result in 0 (Infinity -> 0)
expect(result[0]?.data[0]?.count).toBe(0);
});
});
describe('real-world scenario: article hit ratio', () => {
it('should calculate hit ratio per article path', () => {
const events = [
createEvent('evt1', 'screen_view'),
createEvent('evt2', 'article_card_seen'),
];
const series = [
// Article 1
createSeries(['/articles/1'], events[0]!, [
{ date: '2025-01-01', count: 1000 },
]),
createSeries(['/articles/1'], events[1]!, [
{ date: '2025-01-01', count: 100 },
]),
// Article 2
createSeries(['/articles/2'], events[0]!, [
{ date: '2025-01-01', count: 500 },
]),
createSeries(['/articles/2'], events[1]!, [
{ date: '2025-01-01', count: 200 },
]),
];
const result = withFormula(
createChartInput({ formula: 'A/B', events }),
series,
);
expect(result).toHaveLength(2);
// Article 1: 1000/100 = 10
expect(result[0]?.name).toEqual(['/articles/1']);
expect(result[0]?.data[0]?.count).toBe(10);
// Article 2: 500/200 = 2.5
expect(result[1]?.name).toEqual(['/articles/2']);
expect(result[1]?.data[0]?.count).toBe(2.5);
});
});
});

View File

@@ -1,514 +0,0 @@
import * as mathjs from 'mathjs';
import { last, reverse } from 'ramda';
import sqlstring from 'sqlstring';
import type { ISerieDataItem } from '@openpanel/common';
import {
average,
getPreviousMetric,
groupByLabels,
max,
min,
round,
slug,
sum,
} from '@openpanel/common';
import { alphabetIds } from '@openpanel/constants';
import {
TABLE_NAMES,
chQuery,
createSqlBuilder,
formatClickhouseDate,
getChartPrevStartEndDate,
getChartSql,
getChartStartEndDate,
getEventFiltersWhereClause,
getOrganizationSubscriptionChartEndDate,
getSettingsForProject,
} from '@openpanel/db';
import type {
FinalChart,
IChartEvent,
IChartInput,
IChartInputWithDates,
IGetChartDataInput,
} from '@openpanel/validation';
export function withFormula(
{ formula, events }: IChartInput,
series: Awaited<ReturnType<typeof getChartSerie>>,
) {
if (!formula) {
return series;
}
if (!series || series.length === 0) {
return series;
}
if (!series[0]?.data) {
return series;
}
// Formulas always use alphabet IDs (A, B, C, etc.), not event IDs
// Group series by breakdown values (the name array)
// This allows us to match series from different events that have the same breakdown values
// Detect if we have breakdowns: when there are no breakdowns, name arrays contain event names
// When there are breakdowns, name arrays contain breakdown values (not event names)
const hasBreakdowns = series.some(
(serie) =>
serie.name.length > 0 &&
!events.some(
(event) =>
serie.name[0] === event.name || serie.name[0] === event.displayName,
),
);
const seriesByBreakdown = new Map<string, typeof series>();
series.forEach((serie) => {
let breakdownKey: string;
if (hasBreakdowns) {
// With breakdowns: use the entire name array as the breakdown key
// The name array contains breakdown values (e.g., ["iOS"], ["Android"])
breakdownKey = serie.name.join(':::');
} else {
// Without breakdowns: group all series together regardless of event name
// This allows formulas to combine multiple events
breakdownKey = '';
}
if (!seriesByBreakdown.has(breakdownKey)) {
seriesByBreakdown.set(breakdownKey, []);
}
seriesByBreakdown.get(breakdownKey)!.push(serie);
});
// For each breakdown group, apply the formula
const result: typeof series = [];
for (const [breakdownKey, breakdownSeries] of seriesByBreakdown) {
// Group series by event to ensure we have one series per event
const seriesByEvent = new Map<string, (typeof series)[number]>();
breakdownSeries.forEach((serie) => {
const eventId = serie.event.id ?? serie.event.name;
// If we already have a series for this event in this breakdown group, skip it
// (shouldn't happen, but just in case)
if (!seriesByEvent.has(eventId)) {
seriesByEvent.set(eventId, serie);
}
});
// Get all unique dates across all series in this breakdown group
const allDates = new Set<string>();
breakdownSeries.forEach((serie) => {
serie.data.forEach((item) => {
allDates.add(item.date);
});
});
// Sort dates chronologically
const sortedDates = Array.from(allDates).sort(
(a, b) => new Date(a).getTime() - new Date(b).getTime(),
);
// Apply formula for each date, matching series by event index
const formulaData = sortedDates.map((date) => {
const scope: Record<string, number> = {};
// Build scope using alphabet IDs (A, B, C, etc.) for each event
// This matches how formulas are written (e.g., "A*100", "A/B", "A+B-C")
events.forEach((event, eventIndex) => {
const readableId = alphabetIds[eventIndex];
if (!readableId) {
throw new Error('no alphabet id for serie in withFormula');
}
// Find the series for this event in this breakdown group
const eventId = event.id ?? event.name;
const matchingSerie = seriesByEvent.get(eventId);
// Find the data point for this date
// If the series doesn't exist or the date is missing, use 0
const dataPoint = matchingSerie?.data.find((d) => d.date === date);
scope[readableId] = dataPoint?.count ?? 0;
});
// Evaluate the formula with the scope
let count: number;
try {
count = mathjs.parse(formula).compile().evaluate(scope) as number;
} catch (error) {
// If formula evaluation fails, return 0
count = 0;
}
return {
date,
count:
Number.isNaN(count) || !Number.isFinite(count) ? 0 : round(count, 2),
total_count: breakdownSeries[0]?.data.find((d) => d.date === date)
?.total_count,
};
});
// Use the first series as a template, but replace its data with formula results
// Preserve the breakdown labels (name array) from the original series
const templateSerie = breakdownSeries[0]!;
result.push({
...templateSerie,
data: formulaData,
});
}
return result;
}
function fillFunnel(funnel: { level: number; count: number }[], steps: number) {
const filled = Array.from({ length: steps }, (_, index) => {
const level = index + 1;
const matchingResult = funnel.find((res) => res.level === level);
return {
level,
count: matchingResult ? matchingResult.count : 0,
};
});
// Accumulate counts from top to bottom of the funnel
for (let i = filled.length - 1; i >= 0; i--) {
const step = filled[i];
const prevStep = filled[i + 1];
// If there's a previous step, add the count to the current step
if (step && prevStep) {
step.count += prevStep.count;
}
}
return filled.reverse();
}
export async function getFunnelData({
projectId,
startDate,
endDate,
...payload
}: IChartInput) {
const funnelWindow = (payload.funnelWindow || 24) * 3600;
const funnelGroup =
payload.funnelGroup === 'profile_id'
? [`COALESCE(nullIf(s.profile_id, ''), e.profile_id)`, 'profile_id']
: ['session_id', 'session_id'];
if (!startDate || !endDate) {
throw new Error('startDate and endDate are required');
}
if (payload.events.length === 0) {
return {
totalSessions: 0,
steps: [],
};
}
const funnels = payload.events.map((event) => {
const { sb, getWhere } = createSqlBuilder();
sb.where = getEventFiltersWhereClause(event.filters);
sb.where.name = `name = ${sqlstring.escape(event.name)}`;
return getWhere().replace('WHERE ', '');
});
const commonWhere = `project_id = ${sqlstring.escape(projectId)} AND
created_at >= '${formatClickhouseDate(startDate)}' AND
created_at <= '${formatClickhouseDate(endDate)}'`;
const innerSql = `SELECT
${funnelGroup[0]} AS ${funnelGroup[1]},
windowFunnel(${funnelWindow}, 'strict_increase')(toUnixTimestamp(created_at), ${funnels.join(', ')}) AS level
FROM ${TABLE_NAMES.events} e
${funnelGroup[0] === 'session_id' ? '' : `LEFT JOIN (SELECT profile_id, id FROM sessions WHERE ${commonWhere}) AS s ON s.id = e.session_id`}
WHERE
${commonWhere} AND
name IN (${payload.events.map((event) => sqlstring.escape(event.name)).join(', ')})
GROUP BY ${funnelGroup[0]}`;
const sql = `SELECT level, count() AS count FROM (${innerSql}) WHERE level != 0 GROUP BY level ORDER BY level DESC`;
const funnel = await chQuery<{ level: number; count: number }>(sql);
const maxLevel = payload.events.length;
const filledFunnelRes = fillFunnel(funnel, maxLevel);
const totalSessions = last(filledFunnelRes)?.count ?? 0;
const steps = reverse(filledFunnelRes).reduce(
(acc, item, index, list) => {
const prev = list[index - 1] ?? { count: totalSessions };
const event = payload.events[item.level - 1]!;
return [
...acc,
{
event: {
...event,
displayName: event.displayName ?? event.name,
},
count: item.count,
percent: (item.count / totalSessions) * 100,
dropoffCount: prev.count - item.count,
dropoffPercent: 100 - (item.count / prev.count) * 100,
previousCount: prev.count,
},
];
},
[] as {
event: IChartEvent & { displayName: string };
count: number;
percent: number;
dropoffCount: number;
dropoffPercent: number;
previousCount: number;
}[],
);
return {
totalSessions,
steps,
};
}
export async function getChartSerie(
payload: IGetChartDataInput,
timezone: string,
) {
let result = await chQuery<ISerieDataItem>(
getChartSql({ ...payload, timezone }),
{
session_timezone: timezone,
},
);
if (result.length === 0 && payload.breakdowns.length > 0) {
result = await chQuery<ISerieDataItem>(
getChartSql({
...payload,
breakdowns: [],
timezone,
}),
{
session_timezone: timezone,
},
);
}
return groupByLabels(result).map((serie) => {
return {
...serie,
event: payload.event,
};
});
}
export type IGetChartSerie = Awaited<ReturnType<typeof getChartSeries>>[number];
export async function getChartSeries(
input: IChartInputWithDates,
timezone: string,
) {
const series = (
await Promise.all(
input.events.map(async (event) =>
getChartSerie(
{
...input,
event,
},
timezone,
),
),
)
).flat();
try {
return withFormula(input, series);
} catch (e) {
return series;
}
}
export async function getChart(input: IChartInput) {
const { timezone } = await getSettingsForProject(input.projectId);
const currentPeriod = getChartStartEndDate(input, timezone);
const previousPeriod = getChartPrevStartEndDate(currentPeriod);
const endDate = await getOrganizationSubscriptionChartEndDate(
input.projectId,
currentPeriod.endDate,
);
if (endDate) {
currentPeriod.endDate = endDate;
}
const promises = [getChartSeries({ ...input, ...currentPeriod }, timezone)];
if (input.previous) {
promises.push(
getChartSeries(
{
...input,
...previousPeriod,
},
timezone,
),
);
}
const getSerieId = (serie: IGetChartSerie) =>
[slug(serie.name.join('-')), serie.event.id].filter(Boolean).join('-');
const result = await Promise.all(promises);
const series = result[0]!;
const previousSeries = result[1];
const limit = input.limit || 300;
const offset = input.offset || 0;
const includeEventAlphaId = input.events.length > 1;
const final: FinalChart = {
series: series.map((serie, index) => {
const eventIndex = input.events.findIndex(
(event) => event.id === serie.event.id,
);
const alphaId = alphabetIds[eventIndex];
const previousSerie = previousSeries?.find(
(prevSerie) => getSerieId(prevSerie) === getSerieId(serie),
);
const metrics = {
sum: sum(serie.data.map((item) => item.count)),
average: round(average(serie.data.map((item) => item.count)), 2),
min: min(serie.data.map((item) => item.count)),
max: max(serie.data.map((item) => item.count)),
count: serie.data[0]?.total_count, // We can grab any since all are the same
};
const event = {
id: serie.event.id,
name: serie.event.displayName || serie.event.name,
};
return {
id: getSerieId(serie),
names:
input.breakdowns.length === 0 && serie.event.displayName
? [serie.event.displayName]
: includeEventAlphaId
? [`(${alphaId}) ${serie.name[0]}`, ...serie.name.slice(1)]
: serie.name,
event,
metrics: {
...metrics,
...(input.previous
? {
previous: {
sum: getPreviousMetric(
metrics.sum,
previousSerie
? sum(previousSerie?.data.map((item) => item.count))
: null,
),
average: getPreviousMetric(
metrics.average,
previousSerie
? round(
average(
previousSerie?.data.map((item) => item.count),
),
2,
)
: null,
),
min: getPreviousMetric(
metrics.sum,
previousSerie
? min(previousSerie?.data.map((item) => item.count))
: null,
),
max: getPreviousMetric(
metrics.sum,
previousSerie
? max(previousSerie?.data.map((item) => item.count))
: null,
),
count: getPreviousMetric(
metrics.count ?? 0,
previousSerie?.data[0]?.total_count ?? null,
),
},
}
: {}),
},
data: serie.data.map((item, index) => ({
date: item.date,
count: item.count ?? 0,
previous: previousSerie?.data[index]
? getPreviousMetric(
item.count ?? 0,
previousSerie?.data[index]?.count ?? null,
)
: undefined,
})),
};
}),
metrics: {
sum: 0,
average: 0,
min: 0,
max: 0,
count: undefined,
},
};
// Sort by sum
final.series = final.series
.sort((a, b) => {
if (input.chartType === 'linear') {
const sumA = a.data.reduce((acc, item) => acc + (item.count ?? 0), 0);
const sumB = b.data.reduce((acc, item) => acc + (item.count ?? 0), 0);
return sumB - sumA;
}
return (b.metrics[input.metric] ?? 0) - (a.metrics[input.metric] ?? 0);
})
.slice(offset, limit ? offset + limit : series.length);
final.metrics.sum = sum(final.series.map((item) => item.metrics.sum));
final.metrics.average = round(
average(final.series.map((item) => item.metrics.average)),
2,
);
final.metrics.min = min(final.series.map((item) => item.metrics.min));
final.metrics.max = max(final.series.map((item) => item.metrics.max));
if (input.previous) {
final.metrics.previous = {
sum: getPreviousMetric(
final.metrics.sum,
sum(final.series.map((item) => item.metrics.previous?.sum?.value ?? 0)),
),
average: getPreviousMetric(
final.metrics.average,
round(
average(
final.series.map(
(item) => item.metrics.previous?.average?.value ?? 0,
),
),
2,
),
),
min: getPreviousMetric(
final.metrics.min,
min(final.series.map((item) => item.metrics.previous?.min?.value ?? 0)),
),
max: getPreviousMetric(
final.metrics.max,
max(final.series.map((item) => item.metrics.previous?.max?.value ?? 0)),
),
count: undefined,
};
}
return final;
}

View File

@@ -10,22 +10,32 @@ import {
chQuery, chQuery,
clix, clix,
conversionService, conversionService,
createSqlBuilder,
db, db,
formatClickhouseDate,
funnelService, funnelService,
getChartPrevStartEndDate, getChartPrevStartEndDate,
getChartStartEndDate, getChartStartEndDate,
getEventFiltersWhereClause,
getEventMetasCached, getEventMetasCached,
getProfilesCached,
getSelectPropertyKey, getSelectPropertyKey,
getSettingsForProject, getSettingsForProject,
onlyReportEvents,
} from '@openpanel/db'; } from '@openpanel/db';
import { import {
type IChartEvent,
zChartEvent,
zChartEventFilter,
zChartInput, zChartInput,
zChartSeries,
zCriteria, zCriteria,
zRange, zRange,
zTimeInterval, zTimeInterval,
} from '@openpanel/validation'; } from '@openpanel/validation';
import { round } from '@openpanel/common'; import { round } from '@openpanel/common';
import { ChartEngine } from '@openpanel/db';
import { import {
differenceInDays, differenceInDays,
differenceInMonths, differenceInMonths,
@@ -40,7 +50,6 @@ import {
protectedProcedure, protectedProcedure,
publicProcedure, publicProcedure,
} from '../trpc'; } from '../trpc';
import { getChart } from './chart.helpers';
function utc(date: string | Date) { function utc(date: string | Date) {
if (typeof date === 'string') { if (typeof date === 'string') {
@@ -402,7 +411,8 @@ export const chartRouter = createTRPCRouter({
} }
} }
return getChart(input); // Use new chart engine
return ChartEngine.execute(input);
}), }),
cohort: protectedProcedure cohort: protectedProcedure
.input( .input(
@@ -532,6 +542,200 @@ export const chartRouter = createTRPCRouter({
return processCohortData(cohortData, diffInterval); return processCohortData(cohortData, diffInterval);
}), }),
getProfiles: protectedProcedure
.input(
z.object({
projectId: z.string(),
date: z.string().describe('The date for the data point (ISO string)'),
interval: zTimeInterval.default('day'),
series: zChartSeries,
breakdowns: z.record(z.string(), z.string()).optional(),
}),
)
.query(async ({ input }) => {
const { timezone } = await getSettingsForProject(input.projectId);
const { projectId, date, series } = input;
const limit = 100;
const serie = series[0];
if (!serie) {
throw new Error('Series not found');
}
if (serie.type !== 'event') {
throw new Error('Series must be an event');
}
// Build the date range for the specific interval bucket
const dateObj = new Date(date);
// Build query to get unique profile_ids for this time bucket
const { sb, getSql } = createSqlBuilder();
sb.select.profile_id = 'DISTINCT profile_id';
sb.where = getEventFiltersWhereClause(serie.filters);
sb.where.projectId = `project_id = ${sqlstring.escape(projectId)}`;
sb.where.dateRange = `${clix.toStartOf('created_at', input.interval)} = ${clix.toDate(sqlstring.escape(formatClickhouseDate(dateObj)), input.interval)}`;
if (serie.name !== '*') {
sb.where.eventName = `name = ${sqlstring.escape(serie.name)}`;
}
console.log('> breakdowns', input.breakdowns);
if (input.breakdowns) {
Object.entries(input.breakdowns).forEach(([key, value]) => {
sb.where[`breakdown_${key}`] = `${key} = ${sqlstring.escape(value)}`;
});
}
// // Handle breakdowns if provided
// const anyBreakdownOnProfile = breakdowns.some((breakdown) =>
// breakdown.name.startsWith('profile.'),
// );
// const anyFilterOnProfile = [...event.filters, ...filters].some((filter) =>
// filter.name.startsWith('profile.'),
// );
// if (anyFilterOnProfile || anyBreakdownOnProfile) {
// sb.joins.profiles = `LEFT ANY JOIN (SELECT
// id as "profile.id",
// email as "profile.email",
// first_name as "profile.first_name",
// last_name as "profile.last_name",
// properties as "profile.properties"
// FROM ${TABLE_NAMES.profiles} FINAL WHERE project_id = ${sqlstring.escape(projectId)}) as profile on profile.id = profile_id`;
// }
// Apply breakdown filters if provided
// breakdowns.forEach((breakdown) => {
// // This is simplified - in reality we'd need to match the breakdown value
// // For now, we'll just get all profiles for the time bucket
// });
// Get unique profile IDs
const profileIds = await chQuery<{ profile_id: string }>(getSql());
if (profileIds.length === 0) {
return [];
}
// Fetch profile details
const ids = profileIds.map((p) => p.profile_id).filter(Boolean);
const profiles = await getProfilesCached(ids, projectId);
return profiles;
}),
getFunnelProfiles: protectedProcedure
.input(
z.object({
projectId: z.string(),
startDate: z.string().nullish(),
endDate: z.string().nullish(),
series: zChartSeries,
stepIndex: z.number().describe('0-based index of the funnel step'),
showDropoffs: z
.boolean()
.optional()
.default(false)
.describe(
'If true, show users who dropped off at this step. If false, show users who completed at least this step.',
),
funnelWindow: z.number().optional(),
funnelGroup: z.string().optional(),
breakdowns: z.array(z.object({ name: z.string() })).optional(),
range: zRange,
}),
)
.query(async ({ input }) => {
const { timezone } = await getSettingsForProject(input.projectId);
const {
projectId,
series,
stepIndex,
showDropoffs = false,
funnelWindow,
funnelGroup,
breakdowns = [],
} = input;
const { startDate, endDate } = getChartStartEndDate(input, timezone);
// stepIndex is 0-based, but level is 1-based, so we need level >= stepIndex + 1
const targetLevel = stepIndex + 1;
const eventSeries = onlyReportEvents(series);
if (eventSeries.length === 0) {
throw new Error('At least one event series is required');
}
const funnelWindowSeconds = (funnelWindow || 24) * 3600;
const funnelWindowMilliseconds = funnelWindowSeconds * 1000;
// Use funnel service methods
const group = funnelService.getFunnelGroup(funnelGroup);
// Create sessions CTE if needed
const sessionsCte =
group[0] !== 'session_id'
? funnelService.buildSessionsCte({
projectId,
startDate,
endDate,
timezone,
})
: null;
// Create funnel CTE using funnel service
const funnelCte = funnelService.buildFunnelCte({
projectId,
startDate,
endDate,
eventSeries: eventSeries as IChartEvent[],
funnelWindowMilliseconds,
group,
timezone,
additionalSelects: ['profile_id'],
additionalGroupBy: ['profile_id'],
});
// Build main query
const query = clix(ch, timezone);
if (sessionsCte) {
funnelCte.leftJoin('sessions s', 's.sid = events.session_id');
query.with('sessions', sessionsCte);
}
query.with('funnel', funnelCte);
// Get distinct profile IDs
query
.select(['DISTINCT profile_id'])
.from('funnel')
.where('level', '!=', 0);
if (showDropoffs) {
// Show users who dropped off at this step (completed this step but not the next)
query.where('level', '=', targetLevel);
} else {
// Show users who completed at least this step
query.where('level', '>=', targetLevel);
}
const profileIdsResult = (await query.execute()) as {
profile_id: string;
}[];
if (profileIdsResult.length === 0) {
return [];
}
// Fetch profile details
const ids = profileIdsResult.map((p) => p.profile_id).filter(Boolean);
const profiles = await getProfilesCached(ids, projectId);
return profiles;
}),
}); });
function processCohortData( function processCohortData(

View File

@@ -46,7 +46,7 @@ export const reportRouter = createTRPCRouter({
projectId: dashboard.projectId, projectId: dashboard.projectId,
dashboardId, dashboardId,
name: report.name, name: report.name,
events: report.events, events: report.series,
interval: report.interval, interval: report.interval,
breakdowns: report.breakdowns, breakdowns: report.breakdowns,
chartType: report.chartType, chartType: report.chartType,
@@ -91,7 +91,7 @@ export const reportRouter = createTRPCRouter({
}, },
data: { data: {
name: report.name, name: report.name,
events: report.events, events: report.series,
interval: report.interval, interval: report.interval,
breakdowns: report.breakdowns, breakdowns: report.breakdowns,
chartType: report.chartType, chartType: report.chartType,

View File

@@ -57,12 +57,70 @@ export const zChartEvent = z.object({
.default([]) .default([])
.describe('Filters applied specifically to this event'), .describe('Filters applied specifically to this event'),
}); });
export const zChartFormula = z.object({
id: z
.string()
.optional()
.describe('Unique identifier for the formula configuration'),
type: z.literal('formula'),
formula: z.string().describe('The formula expression (e.g., A+B, A/B)'),
displayName: z
.string()
.optional()
.describe('A user-friendly name for display purposes'),
});
// Event with type field for discriminated union
export const zChartEventWithType = zChartEvent.extend({
type: z.literal('event'),
});
export const zChartEventItem = z.discriminatedUnion('type', [
zChartEventWithType,
zChartFormula,
]);
export const zChartBreakdown = z.object({ export const zChartBreakdown = z.object({
id: z.string().optional(), id: z.string().optional(),
name: z.string(), name: z.string(),
}); });
export const zChartEvents = z.array(zChartEvent); // Support both old format (array of events without type) and new format (array of event/formula items)
// Preprocess to normalize: if item has 'type' field, use discriminated union; otherwise, add type: 'event'
export const zChartSeries = z.preprocess((val) => {
if (!val) return val;
let processedVal = val;
// If the input is an object with numeric keys, convert it to an array
if (typeof val === 'object' && val !== null && !Array.isArray(val)) {
const keys = Object.keys(val).sort(
(a, b) => Number.parseInt(a) - Number.parseInt(b),
);
processedVal = keys.map((key) => (val as any)[key]);
}
if (!Array.isArray(processedVal)) return processedVal;
return processedVal.map((item: any) => {
// If item already has type field, return as-is
if (item && typeof item === 'object' && 'type' in item) {
return item;
}
// Otherwise, add type: 'event' for backward compatibility
if (item && typeof item === 'object' && 'name' in item) {
return { ...item, type: 'event' };
}
return item;
});
}, z
.array(zChartEventItem)
.describe(
'Array of series (events or formulas) to be tracked and displayed in the chart',
));
// Keep zChartEvents as an alias for backward compatibility during migration
export const zChartEvents = zChartSeries;
export const zChartBreakdowns = z.array(zChartBreakdown); export const zChartBreakdowns = z.array(zChartBreakdown);
export const zChartType = z.enum(objectToZodEnums(chartTypes)); export const zChartType = z.enum(objectToZodEnums(chartTypes));
@@ -77,7 +135,7 @@ export const zRange = z.enum(objectToZodEnums(timeWindows));
export const zCriteria = z.enum(['on_or_after', 'on']); export const zCriteria = z.enum(['on_or_after', 'on']);
export const zChartInput = z.object({ export const zChartInputBase = z.object({
chartType: zChartType chartType: zChartType
.default('linear') .default('linear')
.describe('What type of chart should be displayed'), .describe('What type of chart should be displayed'),
@@ -86,8 +144,8 @@ export const zChartInput = z.object({
.describe( .describe(
'The time interval for data aggregation (e.g., day, week, month)', 'The time interval for data aggregation (e.g., day, week, month)',
), ),
events: zChartEvents.describe( series: zChartSeries.describe(
'Array of events to be tracked and displayed in the chart', 'Array of series (events or formulas) to be tracked and displayed in the chart',
), ),
breakdowns: zChartBreakdowns breakdowns: zChartBreakdowns
.default([]) .default([])
@@ -144,7 +202,15 @@ export const zChartInput = z.object({
.describe('Time window in hours for funnel analysis'), .describe('Time window in hours for funnel analysis'),
}); });
export const zReportInput = zChartInput.extend({ export const zChartInput = z.preprocess((val) => {
if (val && typeof val === 'object' && 'events' in val && !('series' in val)) {
// Migrate old 'events' field to 'series'
return { ...val, series: val.events };
}
return val;
}, zChartInputBase);
export const zReportInput = zChartInputBase.extend({
name: z.string().describe('The user-defined name for the report'), name: z.string().describe('The user-defined name for the report'),
lineType: zLineType.describe('The visual style of the line in the chart'), lineType: zLineType.describe('The visual style of the line in the chart'),
unit: z unit: z

View File

@@ -0,0 +1,28 @@
import { zChartEvents } from '.';
const events = [
{
id: 'sAmT',
type: 'event',
name: 'session_end',
segment: 'event',
filters: [],
},
{
id: '5K2v',
type: 'event',
name: 'session_start',
segment: 'event',
filters: [],
},
{
id: 'lQiQ',
type: 'formula',
formula: 'A/B',
displayName: '',
},
];
const res = zChartEvents.safeParse(events);
console.log(res);

View File

@@ -1,11 +1,18 @@
import type { z } from 'zod'; import type { z } from 'zod';
export type UnionOmit<T, K extends keyof any> = T extends any
? Omit<T, K>
: never;
import type { import type {
zChartBreakdown, zChartBreakdown,
zChartEvent, zChartEvent,
zChartEventItem,
zChartEventSegment, zChartEventSegment,
zChartFormula,
zChartInput, zChartInput,
zChartInputAI, zChartInputAI,
zChartSeries,
zChartType, zChartType,
zCriteria, zCriteria,
zLineType, zLineType,
@@ -24,6 +31,11 @@ export type IChartProps = z.infer<typeof zReportInput> & {
previousIndicatorInverted?: boolean; previousIndicatorInverted?: boolean;
}; };
export type IChartEvent = z.infer<typeof zChartEvent>; export type IChartEvent = z.infer<typeof zChartEvent>;
export type IChartFormula = z.infer<typeof zChartFormula>;
export type IChartEventItem = z.infer<typeof zChartEventItem>;
export type IChartSeries = z.infer<typeof zChartSeries>;
// Backward compatibility alias
export type IChartEvents = IChartSeries;
export type IChartEventSegment = z.infer<typeof zChartEventSegment>; export type IChartEventSegment = z.infer<typeof zChartEventSegment>;
export type IChartEventFilter = IChartEvent['filters'][number]; export type IChartEventFilter = IChartEvent['filters'][number];
export type IChartEventFilterValue = export type IChartEventFilterValue =
@@ -45,7 +57,7 @@ export type IGetChartDataInput = {
projectId: string; projectId: string;
startDate: string; startDate: string;
endDate: string; endDate: string;
} & Omit<IChartInput, 'events' | 'name' | 'startDate' | 'endDate' | 'range'>; } & Omit<IChartInput, 'series' | 'name' | 'startDate' | 'endDate' | 'range'>;
export type ICriteria = z.infer<typeof zCriteria>; export type ICriteria = z.infer<typeof zCriteria>;
export type PreviousValue = export type PreviousValue =
@@ -77,6 +89,7 @@ export type IChartSerie = {
event: { event: {
id?: string; id?: string;
name: string; name: string;
breakdowns?: Record<string, string>;
}; };
metrics: Metrics; metrics: Metrics;
data: { data: {

13
pnpm-lock.yaml generated
View File

@@ -629,9 +629,6 @@ importers:
lucide-react: lucide-react:
specifier: ^0.476.0 specifier: ^0.476.0
version: 0.476.0(react@19.1.1) version: 0.476.0(react@19.1.1)
mathjs:
specifier: ^12.3.2
version: 12.3.2
mitt: mitt:
specifier: ^3.0.1 specifier: ^3.0.1
version: 3.0.1 version: 3.0.1
@@ -1071,6 +1068,9 @@ importers:
jiti: jiti:
specifier: ^2.4.1 specifier: ^2.4.1
version: 2.4.1 version: 2.4.1
mathjs:
specifier: ^12.3.2
version: 12.3.2
prisma-json-types-generator: prisma-json-types-generator:
specifier: ^3.1.1 specifier: ^3.1.1
version: 3.1.1(prisma@6.14.0(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3) version: 3.1.1(prisma@6.14.0(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3)
@@ -10317,9 +10317,6 @@ packages:
decimal.js-light@2.5.1: decimal.js-light@2.5.1:
resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
decimal.js@10.4.3:
resolution: {integrity: sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==}
decimal.js@10.6.0: decimal.js@10.6.0:
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
@@ -26295,8 +26292,6 @@ snapshots:
decimal.js-light@2.5.1: {} decimal.js-light@2.5.1: {}
decimal.js@10.4.3: {}
decimal.js@10.6.0: {} decimal.js@10.6.0: {}
decode-named-character-reference@1.0.2: decode-named-character-reference@1.0.2:
@@ -29314,7 +29309,7 @@ snapshots:
dependencies: dependencies:
'@babel/runtime': 7.23.9 '@babel/runtime': 7.23.9
complex.js: 2.1.1 complex.js: 2.1.1
decimal.js: 10.4.3 decimal.js: 10.6.0
escape-latex: 1.2.0 escape-latex: 1.2.0
fraction.js: 4.3.4 fraction.js: 4.3.4
javascript-natural-sort: 0.7.1 javascript-natural-sort: 0.7.1