diff --git a/.vscode/settings.json b/.vscode/settings.json index e1eb0bea..95b7c494 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -17,7 +17,7 @@ "editor.defaultFormatter": "biomejs.biome" }, "[json]": { - "editor.defaultFormatter": "biomejs.biome" + "editor.defaultFormatter": "vscode.json-language-features" }, "editor.formatOnSave": true, "tailwindCSS.experimental.classRegex": [ diff --git a/apps/api/src/controllers/export.controller.ts b/apps/api/src/controllers/export.controller.ts index cbaa0608..ac622908 100644 --- a/apps/api/src/controllers/export.controller.ts +++ b/apps/api/src/controllers/export.controller.ts @@ -12,8 +12,8 @@ import { getEventsCountCached, getSettingsForProject, } from '@openpanel/db'; -import { getChart } from '@openpanel/trpc/src/routers/chart.helpers'; -import { zChartEvent, zChartInput } from '@openpanel/validation'; +import { ChartEngine } from '@openpanel/db'; +import { zChartEvent, zChartInputBase } from '@openpanel/validation'; import { omit } from 'ramda'; async function getProjectId( @@ -139,7 +139,7 @@ export async function events( }); } -const chartSchemeFull = zChartInput +const chartSchemeFull = zChartInputBase .pick({ breakdowns: true, interval: true, @@ -151,14 +151,27 @@ const chartSchemeFull = zChartInput .extend({ project_id: z.string().optional(), projectId: z.string().optional(), - events: z.array( - z.object({ - name: z.string(), - filters: zChartEvent.shape.filters.optional(), - segment: zChartEvent.shape.segment.optional(), - property: zChartEvent.shape.property.optional(), - }), - ), + series: z + .array( + z.object({ + name: z.string(), + filters: zChartEvent.shape.filters.optional(), + segment: zChartEvent.shape.segment.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( @@ -179,9 +192,17 @@ export async function charts( const projectId = await getProjectId(request, reply); 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, startDate: rest.startDate ? DateTime.fromISO(rest.startDate) @@ -194,11 +215,7 @@ export async function charts( .toFormat('yyyy-MM-dd HH:mm:ss') : undefined, projectId, - events: events.map((event) => ({ - ...event, - segment: event.segment ?? 'event', - filters: event.filters ?? [], - })), + series: eventSeries, chartType: 'linear', metric: 'sum', }); diff --git a/apps/api/src/utils/ai-tools.ts b/apps/api/src/utils/ai-tools.ts index ffe91e1c..e261b551 100644 --- a/apps/api/src/utils/ai-tools.ts +++ b/apps/api/src/utils/ai-tools.ts @@ -7,8 +7,8 @@ import { ch, clix, } from '@openpanel/db'; +import { ChartEngine } from '@openpanel/db'; import { getCache } from '@openpanel/redis'; -import { getChart } from '@openpanel/trpc/src/routers/chart.helpers'; import { zChartInputAI } from '@openpanel/validation'; import { tool } from 'ai'; import { z } from 'zod'; diff --git a/apps/start/package.json b/apps/start/package.json index bc45a52f..ea949289 100644 --- a/apps/start/package.json +++ b/apps/start/package.json @@ -103,7 +103,6 @@ "lodash.throttle": "^4.1.1", "lottie-react": "^2.4.0", "lucide-react": "^0.476.0", - "mathjs": "^12.3.2", "mitt": "^3.0.1", "nuqs": "^2.5.2", "prisma-error-enum": "^0.1.3", diff --git a/apps/start/src/components/overview/overview-metric-card.tsx b/apps/start/src/components/overview/overview-metric-card.tsx index 598dc47b..2b4cc039 100644 --- a/apps/start/src/components/overview/overview-metric-card.tsx +++ b/apps/start/src/components/overview/overview-metric-card.tsx @@ -153,7 +153,7 @@ export function OverviewMetricCard({ width={width} height={height / 4} data={data} - style={{ marginTop: (height / 4) * 3 }} + style={{ marginTop: (height / 4) * 3, background: 'transparent' }} onMouseMove={(event) => { setCurrentIndex(event.activeTooltipIndex ?? null); }} diff --git a/apps/start/src/components/overview/overview-top-devices.tsx b/apps/start/src/components/overview/overview-top-devices.tsx index d94be03f..afce59db 100644 --- a/apps/start/src/components/overview/overview-top-devices.tsx +++ b/apps/start/src/components/overview/overview-top-devices.tsx @@ -45,8 +45,9 @@ export default function OverviewTopDevices({ projectId, startDate, endDate, - events: [ + series: [ { + type: 'event', segment: 'user', filters, id: 'A', @@ -81,8 +82,9 @@ export default function OverviewTopDevices({ projectId, startDate, endDate, - events: [ + series: [ { + type: 'event', segment: 'user', filters, id: 'A', @@ -120,8 +122,9 @@ export default function OverviewTopDevices({ projectId, startDate, endDate, - events: [ + series: [ { + type: 'event', segment: 'user', filters, id: 'A', @@ -160,8 +163,9 @@ export default function OverviewTopDevices({ projectId, startDate, endDate, - events: [ + series: [ { + type: 'event', segment: 'user', filters, id: 'A', @@ -199,8 +203,9 @@ export default function OverviewTopDevices({ projectId, startDate, endDate, - events: [ + series: [ { + type: 'event', segment: 'user', filters, id: 'A', @@ -239,8 +244,9 @@ export default function OverviewTopDevices({ projectId, startDate, endDate, - events: [ + series: [ { + type: 'event', segment: 'user', filters, id: 'A', @@ -278,8 +284,9 @@ export default function OverviewTopDevices({ projectId, startDate, endDate, - events: [ + series: [ { + type: 'event', segment: 'user', filters, id: 'A', diff --git a/apps/start/src/components/overview/overview-top-events.tsx b/apps/start/src/components/overview/overview-top-events.tsx index e5d12ea6..a0e17bdd 100644 --- a/apps/start/src/components/overview/overview-top-events.tsx +++ b/apps/start/src/components/overview/overview-top-events.tsx @@ -7,6 +7,7 @@ import type { IChartType } from '@openpanel/validation'; import { useTRPC } from '@/integrations/trpc/react'; import { useQuery } from '@tanstack/react-query'; +import { SerieIcon } from '../report-chart/common/serie-icon'; import { Widget, WidgetBody } from '../widget'; import { OverviewChartToggle } from './overview-chart-toggle'; import { WidgetButtons, WidgetFooter, WidgetHead } from './overview-widget'; @@ -37,8 +38,9 @@ export default function OverviewTopEvents({ projectId, startDate, endDate, - events: [ + series: [ { + type: 'event', segment: 'event', filters: [ ...filters, @@ -78,8 +80,9 @@ export default function OverviewTopEvents({ projectId, startDate, endDate, - events: [ + series: [ { + type: 'event', segment: 'event', filters: [...filters], id: 'A', @@ -112,8 +115,9 @@ export default function OverviewTopEvents({ projectId, startDate, endDate, - events: [ + series: [ { + type: 'event', segment: 'event', filters: [ ...filters, @@ -168,7 +172,13 @@ export default function OverviewTopEvents({ {item.prefix && ( - {item.prefix} + + {countries[ + item.prefix as keyof typeof countries + ] ?? item.prefix} + )} - {item.name || 'Not set'} + {(countries[item.name as keyof typeof countries] ?? + item.name) || + 'Not set'} ); @@ -146,8 +153,9 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) { projectId, startDate, endDate, - events: [ + series: [ { + type: 'event', segment: 'event', filters, id: 'A', diff --git a/apps/start/src/components/profiles/profile-charts.tsx b/apps/start/src/components/profiles/profile-charts.tsx index 16d7ce20..41797dd9 100644 --- a/apps/start/src/components/profiles/profile-charts.tsx +++ b/apps/start/src/components/profiles/profile-charts.tsx @@ -15,8 +15,9 @@ export const ProfileCharts = memo( const pageViewsChart: IChartProps = { projectId, chartType: 'linear', - events: [ + series: [ { + type: 'event', segment: 'event', filters: [ { @@ -48,8 +49,9 @@ export const ProfileCharts = memo( const eventsChart: IChartProps = { projectId, chartType: 'linear', - events: [ + series: [ { + type: 'event', segment: 'event', filters: [ { diff --git a/apps/start/src/components/report-chart/area/chart.tsx b/apps/start/src/components/report-chart/area/chart.tsx index 08d174f9..872ed19b 100644 --- a/apps/start/src/components/report-chart/area/chart.tsx +++ b/apps/start/src/components/report-chart/area/chart.tsx @@ -7,6 +7,7 @@ import { cn } from '@/utils/cn'; import { getChartColor } from '@/utils/theme'; import { useQuery } from '@tanstack/react-query'; import { isSameDay, isSameHour, isSameMonth, isSameWeek } from 'date-fns'; +import { BookmarkIcon, UsersIcon } from 'lucide-react'; import { last } from 'ramda'; import { useCallback } from 'react'; import { @@ -25,6 +26,10 @@ import { import { useDashedStroke } from '@/hooks/use-dashed-stroke'; import { useXAxisProps, useYAxisProps } from '../common/axis'; +import { + ChartClickMenu, + type ChartClickMenuItem, +} from '../common/chart-click-menu'; import { ReportChartTooltip } from '../common/report-chart-tooltip'; import { ReportTable } from '../common/report-table'; import { SerieIcon } from '../common/serie-icon'; @@ -45,6 +50,8 @@ export function Chart({ data }: Props) { endDate, range, lineType, + series: reportSeries, + breakdowns, }, isEditMode, options: { hideXAxis, hideYAxis }, @@ -126,16 +133,66 @@ export function Chart({ data }: Props) { interval, }); - const handleChartClick = useCallback((e: any) => { - if (e?.activePayload?.[0]) { - const clickedData = e.activePayload[0].payload; - if (clickedData.date) { - pushModal('AddReference', { - datetime: new Date(clickedData.date).toISOString(), + const getMenuItems = useCallback( + (e: any, clickedData: any): ChartClickMenuItem[] => { + const items: ChartClickMenuItem[] = []; + + if (!clickedData?.date) { + return items; + } + + // View Users - only show if we have projectId + if (projectId) { + items.push({ + label: 'View Users', + icon: , + 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: , + onClick: () => { + pushModal('AddReference', { + datetime: new Date(clickedData.date).toISOString(), + }); + }, + }); + + return items; + }, + [ + projectId, + data, + reportSeries, + breakdowns, + interval, + startDate, + endDate, + range, + previous, + ], + ); const { getStrokeDasharray, calcStrokeDasharray, handleAnimationEnd } = useDashedStroke({ @@ -144,106 +201,114 @@ export function Chart({ data }: Props) { return ( -
- - - - - - {references.data?.map((ref) => ( - +
+ + + + - ))} - - - } /> - } /> - {series.map((serie) => { - const color = getChartColor(serie.index); - return ( - - - - - - - ); - })} - {series.map((serie) => { - const color = getChartColor(serie.index); - return ( - + {references.data?.map((ref) => ( + - ); - })} - {previous && - series.map((serie) => { + ))} + + + } /> + } /> + {series.map((serie) => { + const color = getChartColor(serie.index); + return ( + + + + + + + ); + })} + {series.map((serie) => { const color = getChartColor(serie.index); return ( ); })} - - -
- {isEditMode && ( - - )} + {previous && + series.map((serie) => { + const color = getChartColor(serie.index); + return ( + + ); + })} +
+
+
+ {isEditMode && ( + + )} +
); } diff --git a/apps/start/src/components/report-chart/common/chart-click-menu.tsx b/apps/start/src/components/report-chart/common/chart-click-menu.tsx new file mode 100644 index 00000000..197b0ab3 --- /dev/null +++ b/apps/start/src/components/report-chart/common/chart-click-menu.tsx @@ -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(null); + const [clickPosition, setClickPosition] = useState<{ + x: number; + y: number; + } | null>(null); + const [clickedData, setClickedData] = useState(null); + + const [clickEvent, setClickEvent] = useState(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 ( +
+ + +
+ + + {menuItems.map((item) => ( + handleItemClick(item)} + disabled={item.disabled} + > + {item.icon && {item.icon}} + {item.label} + + ))} + + + {chartWithClickHandler} +
+ ); +}); + +ChartClickMenu.displayName = 'ChartClickMenu'; diff --git a/apps/start/src/components/report-chart/common/empty.tsx b/apps/start/src/components/report-chart/common/empty.tsx index e5f33702..73341a7c 100644 --- a/apps/start/src/components/report-chart/common/empty.tsx +++ b/apps/start/src/components/report-chart/common/empty.tsx @@ -17,10 +17,10 @@ export function ReportChartEmpty({ }) { const { isEditMode, - report: { events }, + report: { series }, } = useReportChartContext(); - if (events.length === 0) { + if (!series || series.length === 0) { return (
diff --git a/apps/start/src/components/report-chart/common/report-table-toolbar.tsx b/apps/start/src/components/report-chart/common/report-table-toolbar.tsx new file mode 100644 index 00000000..00536876 --- /dev/null +++ b/apps/start/src/components/report-chart/common/report-table-toolbar.tsx @@ -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 ( +
+ {onSearchChange && ( +
+ + onSearchChange(e.target.value)} + className="pl-8" + /> +
+ )} +
+ {onToggleGrouped && ( + + )} + {onUnselectAll && ( + + )} +
+
+ ); +} diff --git a/apps/start/src/components/report-chart/common/report-table-utils.ts b/apps/start/src/components/report-chart/common/report-table-utils.ts new file mode 100644 index 00000000..83187dff --- /dev/null +++ b/apps/start/src/components/report-chart/common/report-table-utils.ts @@ -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; // 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 = { + group: string; + items: Array | 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( + items: T[], +): Array> { + const rootGroups = new Map>(); + + 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 => + 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 = { + 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( + groupedItems: Array | 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( + groups: Array>, + groupKey: string, +): GroupedItem | 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>, + breakdownCount: number, +): ExpandableTableRow[] { + const result: ExpandableTableRow[] = []; + + function processGroup( + group: GroupedItem, + parentPath: string[] = [], + ): ExpandableTableRow[] { + const currentPath = [...parentPath, group.group]; + const subRows: ExpandableTableRow[] = []; + + // Separate nested groups from individual data items + const nestedGroups: GroupedItem[] = []; + 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( + groups: Array>, + collapsedGroups: Set, + breakdownCount: number, +): GroupedTableRow[] { + const rows: GroupedTableRow[] = []; + + function processGroup( + group: GroupedItem, + 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[] = []; + 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(); + 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 = {}; + 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> { + 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(); + 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 = {}; + 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(); + 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>; + 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, + }; +} diff --git a/apps/start/src/components/report-chart/common/report-table.tsx b/apps/start/src/components/report-chart/common/report-table.tsx index a4d21892..8754e8fa 100644 --- a/apps/start/src/components/report-chart/common/report-table.tsx +++ b/apps/start/src/components/report-chart/common/report-table.tsx @@ -1,193 +1,1655 @@ -import { Pagination, usePagination } from '@/components/pagination'; -import { Stats, StatsCard } from '@/components/stats'; -import { Badge } from '@/components/ui/badge'; import { Checkbox } from '@/components/ui/checkbox'; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/ui/table'; -import { Tooltiper } from '@/components/ui/tooltip'; import { useFormatDateInterval } from '@/hooks/use-format-date-interval'; import { useNumber } from '@/hooks/use-numer-formatter'; import { useSelector } from '@/redux'; -import { getPropertyLabel } from '@/translations/properties'; import type { IChartData } from '@/trpc/client'; +import { cn } from '@/utils/cn'; import { getChartColor } from '@/utils/theme'; +import type { ColumnDef, Header, Row } from '@tanstack/react-table'; +import { + type ExpandedState, + type SortingState, + flexRender, + getCoreRowModel, + getExpandedRowModel, + getFilteredRowModel, + getSortedRowModel, + useReactTable, +} from '@tanstack/react-table'; +import { + type VirtualItem, + useVirtualizer, + useWindowVirtualizer, +} from '@tanstack/react-virtual'; +import throttle from 'lodash.throttle'; +import { ChevronDown, ChevronRight } from 'lucide-react'; import type * as React from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; -import { logDependencies } from 'mathjs'; -import { PreviousDiffIndicator } from './previous-diff-indicator'; +import { Tooltiper } from '@/components/ui/tooltip'; +import { ReportTableToolbar } from './report-table-toolbar'; +import { + type ExpandableTableRow, + type GroupedItem, + type GroupedTableRow, + type TableRow, + groupsToExpandableRows, + groupsToTableRows, + transformToHierarchicalGroups, + transformToTableData, +} from './report-table-utils'; import { SerieName } from './serie-name'; +declare module '@tanstack/react-table' { + interface ColumnMeta { + pinned?: 'left' | 'right'; + isBreakdown?: boolean; + breakdownIndex?: number; + } +} + interface ReportTableProps { data: IChartData; - visibleSeries: IChartData['series']; + visibleSeries: IChartData['series'] | string[]; setVisibleSeries: React.Dispatch>; } -const ROWS_LIMIT = 50; +const DEFAULT_COLUMN_WIDTH = 150; +const ROW_HEIGHT = 48; // h-12 + +interface VirtualRowProps { + row: Row; + virtualRow: VirtualItem; + pinningStylesMap: Map; + headers: Header[]; + isResizingRef: React.MutableRefObject; + resizingColumnId: string | null; + setResizingColumnId: (id: string | null) => void; + // Horizontal virtualization props + leftPinnedColumns: Header['column'][]; + scrollableColumns: Header['column'][]; + rightPinnedColumns: Header['column'][]; + virtualColumns: VirtualItem[]; + leftPinnedWidth: number; + scrollableColumnsTotalWidth: number; + rightPinnedWidth: number; +} + +const VirtualRow = function VirtualRow({ + row, + virtualRow, + pinningStylesMap, + headers, + isResizingRef, + resizingColumnId, + setResizingColumnId, + leftPinnedColumns, + scrollableColumns, + rightPinnedColumns, + virtualColumns, + leftPinnedWidth, + scrollableColumnsTotalWidth, + rightPinnedWidth, +}: VirtualRowProps) { + const cells = row.getVisibleCells(); + + const renderCell = ( + column: Header['column'], + header: Header | undefined, + ) => { + const cell = cells.find((c) => c.column.id === column.id); + if (!cell || !header) return null; + + const isBreakdown = column.columnDef.meta?.isBreakdown ?? false; + const pinningStyles = pinningStylesMap.get(column.id) ?? {}; + const canResize = column.getCanResize(); + const isPinned = column.columnDef.meta?.pinned === 'left'; + const isResizing = resizingColumnId === column.id; + + return ( +
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} + {canResize && isPinned && ( +
{ + e.stopPropagation(); + isResizingRef.current = true; + setResizingColumnId(column.id); + header.getResizeHandler()(e); + }} + onMouseUp={() => { + setTimeout(() => { + isResizingRef.current = false; + setResizingColumnId(null); + }, 0); + }} + onTouchStart={(e) => { + e.stopPropagation(); + isResizingRef.current = true; + setResizingColumnId(column.id); + header.getResizeHandler()(e); + }} + onTouchEnd={() => { + setTimeout(() => { + isResizingRef.current = false; + setResizingColumnId(null); + }, 0); + }} + className={cn( + 'absolute right-0 top-0 h-full w-1 cursor-col-resize touch-none select-none bg-transparent hover:bg-primary/50 transition-colors', + isResizing && 'bg-primary', + )} + /> + )} +
+ ); + }; + + return ( +
+ {/* Left Pinned Columns */} + {leftPinnedColumns.map((column) => { + const header = headers.find((h) => h.column.id === column.id); + return renderCell(column, header); + })} + + {/* Scrollable Columns (Virtualized) */} +
+ {virtualColumns.map((virtualCol) => { + const column = scrollableColumns[virtualCol.index]; + if (!column) return null; + const header = headers.find((h) => h.column.id === column.id); + const cell = cells.find((c) => c.column.id === column.id); + if (!cell || !header) return null; + + return ( +
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+ ); + })} +
+ + {/* Right Pinned Columns */} + {rightPinnedColumns.map((column) => { + const header = headers.find((h) => h.column.id === column.id); + return renderCell(column, header); + })} +
+ ); +}; export function ReportTable({ data, visibleSeries, setVisibleSeries, }: ReportTableProps) { - const { setPage, paginate, page } = usePagination(ROWS_LIMIT); + const [grouped, setGrouped] = useState(false); + const [expanded, setExpanded] = useState({}); + const [sorting, setSorting] = useState([]); + const [globalFilter, setGlobalFilter] = useState(''); + const [columnSizing, setColumnSizing] = useState>({}); + const [resizingColumnId, setResizingColumnId] = useState(null); + const isResizingRef = useRef(false); + const parentRef = useRef(null); + const [scrollMargin, setScrollMargin] = useState(0); const number = useNumber(); const interval = useSelector((state) => state.report.interval); const breakdowns = useSelector((state) => state.report.breakdowns); + const formatDate = useFormatDateInterval({ interval, short: true, }); - function handleChange(name: string, checked: boolean) { - setVisibleSeries((prev) => { - if (checked) { - return [...prev, name]; + // Transform data to hierarchical groups or flat rows + const { + groups: hierarchicalGroups, + rows: flatRows, + dates, + breakdownPropertyNames, + } = useMemo(() => { + if (grouped) { + const result = transformToHierarchicalGroups(data, breakdowns); + return { + groups: result.groups, + rows: null, + dates: result.dates, + breakdownPropertyNames: result.breakdownPropertyNames, + }; + } + const result = transformToTableData(data, breakdowns, false); + return { + groups: null, + rows: result.rows as TableRow[], + dates: result.dates, + breakdownPropertyNames: result.breakdownPropertyNames, + }; + }, [data, breakdowns, grouped]); + + // Convert hierarchical groups to expandable rows (for TanStack Table's expanding feature) + const expandableRows = useMemo(() => { + if (!grouped || !hierarchicalGroups || hierarchicalGroups.length === 0) { + return null; + } + + return groupsToExpandableRows( + hierarchicalGroups, + breakdownPropertyNames.length, + ); + }, [grouped, hierarchicalGroups, breakdownPropertyNames.length]); + + // Use expandable rows if available, otherwise use flat rows + const rows = expandableRows ?? flatRows ?? []; + + // Filter rows based on global search and apply sorting + const filteredRows = 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.toLowerCase().includes(searchLower)) return true; + + // Search in breakdown values + if ( + row.breakdownValues.some((val) => + val?.toLowerCase().includes(searchLower), + ) + ) { + return true; + } + + // Search in metric values + const metrics = ['count', 'sum', 'average', 'min', 'max'] as const; + if ( + metrics.some((metric) => + String(row[metric]).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 grouped, always sort groups by highest count, then sort within each group + if (grouped && result.length > 0) { + const groupedRows = result as ExpandableTableRow[] | GroupedTableRow[]; + + // Sort function based on current sort state + const sortFn = ( + a: ExpandableTableRow | GroupedTableRow | TableRow, + b: ExpandableTableRow | GroupedTableRow | TableRow, + ) => { + // If no sorting is selected, return 0 (no change) + if (sorting.length === 0) return 0; + + for (const sort of sorting) { + const { id, desc } = sort; + let aValue: any; + let bValue: any; + + if (id === 'serie-name') { + aValue = a.serieName ?? ''; + bValue = b.serieName ?? ''; + } else if (id.startsWith('breakdown-')) { + const index = Number.parseInt(id.replace('breakdown-', ''), 10); + if ('breakdownDisplay' in a && a.breakdownDisplay) { + aValue = a.breakdownDisplay[index] ?? ''; + } else { + aValue = a.breakdownValues[index] ?? ''; + } + if ('breakdownDisplay' in b && b.breakdownDisplay) { + bValue = b.breakdownDisplay[index] ?? ''; + } else { + bValue = b.breakdownValues[index] ?? ''; + } + } else if (id.startsWith('metric-')) { + const metric = id.replace('metric-', '') as keyof TableRow; + aValue = a[metric] ?? 0; + bValue = b[metric] ?? 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; + }; + + // For expandable rows, we need to sort recursively + function sortExpandableRows( + rows: ExpandableTableRow[], + isTopLevel = true, + ): ExpandableTableRow[] { + // Sort rows: groups by count first (only at top level), then apply user sort + const sorted = [...rows].sort((a, b) => { + // At top level, sort groups by count first + if (isTopLevel) { + const aIsGroupHeader = 'isGroupHeader' in a && a.isGroupHeader; + const bIsGroupHeader = 'isGroupHeader' in b && b.isGroupHeader; + + if (aIsGroupHeader && bIsGroupHeader) { + const aLevel = 'groupLevel' in a ? (a.groupLevel ?? -1) : -1; + const bLevel = 'groupLevel' in b ? (b.groupLevel ?? -1) : -1; + + // Same level groups: sort by count first (always, regardless of user sort) + if (aLevel === bLevel) { + const aCount = a.count ?? 0; + const bCount = b.count ?? 0; + if (aCount !== bCount) { + return bCount - aCount; // Highest first + } + // If counts are equal, fall through to user sort + } + } + } + + // Apply user's sort criteria (for all rows, including within groups) + return sortFn(a, b); + }); + + // Sort subRows recursively (within each group) - these are NOT top level + return sorted.map((row) => { + if ('subRows' in row && row.subRows) { + return { + ...row, + subRows: sortExpandableRows(row.subRows, false), + }; + } + return row; + }); } - return prev.filter((item) => item !== name); + + return sortExpandableRows(groupedRows as ExpandableTableRow[]); + } + + // For flat mode, apply sorting + if (!grouped && result.length > 0 && sorting.length > 0) { + return [...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 ?? ''; + bValue = b.serieName ?? ''; + } else if (id.startsWith('breakdown-')) { + const index = Number.parseInt(id.replace('breakdown-', ''), 10); + aValue = a.breakdownValues[index] ?? ''; + bValue = b.breakdownValues[index] ?? ''; + } else if (id.startsWith('metric-')) { + const metric = id.replace('metric-', '') as keyof TableRow; + aValue = a[metric] ?? 0; + bValue = b[metric] ?? 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, grouped, sorting]); + + // Calculate min/max values for color visualization + const { metricRanges, dateRanges } = useMemo(() => { + const metricRanges: Record = { + count: { + min: Number.POSITIVE_INFINITY, + max: Number.NEGATIVE_INFINITY, + }, + sum: { min: Number.POSITIVE_INFINITY, max: Number.NEGATIVE_INFINITY }, + average: { + min: Number.POSITIVE_INFINITY, + max: Number.NEGATIVE_INFINITY, + }, + min: { min: Number.POSITIVE_INFINITY, max: Number.NEGATIVE_INFINITY }, + max: { min: Number.POSITIVE_INFINITY, max: Number.NEGATIVE_INFINITY }, + }; + + const dateRanges: Record = {}; + dates.forEach((date) => { + dateRanges[date] = { + min: Number.POSITIVE_INFINITY, + max: Number.NEGATIVE_INFINITY, + }; }); + + // Helper function to flatten expandable rows and get only individual rows + function getIndividualRows( + rows: (ExpandableTableRow | TableRow)[], + ): TableRow[] { + const individualRows: TableRow[] = []; + for (const row of rows) { + const isGroupHeader = + 'isGroupHeader' in row && row.isGroupHeader === true; + const isSummary = 'isSummaryRow' in row && row.isSummaryRow === true; + + if (!isGroupHeader && !isSummary) { + // It's an individual row - add it + individualRows.push(row as TableRow); + } + + // Always recursively process subRows if they exist (regardless of whether this is a group header) + if ('subRows' in row && row.subRows && Array.isArray(row.subRows)) { + individualRows.push(...getIndividualRows(row.subRows)); + } + } + return individualRows; + } + + // Get only individual rows from all rows to ensure consistent ranges + const individualRows = getIndividualRows(rows); + const isSingleSeries = individualRows.length === 1; + + if (isSingleSeries) { + // For single series, calculate ranges from date values + const singleRow = individualRows[0]!; + const allDateValues = dates.map( + (date) => singleRow.dateValues[date] ?? 0, + ); + const dateMin = Math.min(...allDateValues); + const dateMax = Math.max(...allDateValues); + + // For date columns, use the range across all dates + dates.forEach((date) => { + dateRanges[date] = { + min: dateMin, + max: dateMax, + }; + }); + + // For metric columns, use date values to create meaningful ranges + // This ensures we can still show color variation even with one series + metricRanges.count = { min: dateMin, max: dateMax }; + metricRanges.sum = { min: dateMin, max: dateMax }; + metricRanges.average = { min: dateMin, max: dateMax }; + metricRanges.min = { min: dateMin, max: dateMax }; + metricRanges.max = { min: dateMin, max: dateMax }; + } else { + // Multiple series: calculate ranges across individual rows only + if (individualRows.length === 0) { + // No individual rows found - this shouldn't happen, but handle gracefully + } else { + individualRows.forEach((row) => { + // Calculate metric ranges + Object.keys(metricRanges).forEach((key) => { + const value = row[key as keyof typeof row] as number; + if (typeof value === 'number' && !Number.isNaN(value)) { + metricRanges[key]!.min = Math.min(metricRanges[key]!.min, value); + metricRanges[key]!.max = Math.max(metricRanges[key]!.max, value); + } + }); + + // Calculate date ranges + dates.forEach((date) => { + const value = row.dateValues[date] ?? 0; + if (!dateRanges[date]) { + dateRanges[date] = { + min: Number.POSITIVE_INFINITY, + max: Number.NEGATIVE_INFINITY, + }; + } + if (typeof value === 'number' && !Number.isNaN(value)) { + 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 and opacity for a value + // Returns both style and opacity (for text color calculation) to avoid parsing + const getCellBackgroundStyle = ( + value: number, + min: number, + max: number, + colorClass: 'purple' | 'emerald' = 'emerald', + ): { style: React.CSSProperties; opacity: number } => { + if (value === 0) { + return { style: {}, opacity: 0 }; + } + + // If min equals max (e.g. single row or all values same), show moderate opacity + let opacity: number; + if (max === min) { + opacity = 0.5; + } else { + const percentage = (value - min) / (max - min); + opacity = Math.max(0.05, Math.min(1, percentage)); + } + + // Use rgba colors directly instead of opacity + background class + const backgroundColor = + colorClass === 'purple' + ? `rgba(168, 85, 247, ${opacity})` // purple-500 + : `rgba(16, 185, 129, ${opacity})`; // emerald-500 + + return { + style: { backgroundColor }, + opacity, + }; + }; + + // Normalize visibleSeries to string array + const visibleSeriesIds = useMemo(() => { + if (visibleSeries.length === 0) return []; + if (typeof visibleSeries[0] === 'string') { + return visibleSeries as string[]; + } + return (visibleSeries as IChartData['series']).map((s) => s.id); + }, [visibleSeries]); + + // Create a hash of visibleSeriesIds to track checkbox state changes + const visibleSeriesIdsHash = useMemo(() => { + return visibleSeriesIds.sort().join(','); + }, [visibleSeriesIds]); + + // Get serie index for color + const getSerieIndex = (serieId: string): number => { + return data.series.findIndex((s) => s.id === serieId); + }; + + // Toggle serie visibility + const toggleSerieVisibility = (serieId: string) => { + setVisibleSeries((prev) => { + if (prev.includes(serieId)) { + return prev.filter((id) => id !== serieId); + } + return [...prev, serieId]; + }); + }; + + // Toggle group collapse (now handled by TanStack Table's expanding feature) + // This is kept for backward compatibility with header click handlers + const toggleGroupCollapse = (groupKey: string) => { + // This will be handled by TanStack Table's row expansion + // We can find the row by groupKey and toggle it + // For now, this is a no-op as TanStack Table handles it + }; + + // Define columns + const columns = useMemo[]>(() => { + const cols: ColumnDef[] = []; + + // Serie name column (pinned left) with checkbox + cols.push({ + id: 'serie-name', + header: 'Serie', + accessorKey: 'serieName', + enableSorting: true, + size: DEFAULT_COLUMN_WIDTH, + meta: { + pinned: 'left', + }, + cell: ({ row }) => { + const original = row.original; + const serieName = original.serieName; + const serieId = original.serieId; + const isVisible = visibleSeriesIds.includes(serieId); + const serieIndex = getSerieIndex(serieId); + const color = getChartColor(serieIndex); + + // Check if this serie name matches the first row in the group (for muted styling) + let isMuted = false; + let isFirstRowInGroup = false; + if ( + grouped && + 'groupKey' in original && + original.groupKey && + !original.isSummaryRow + ) { + // Find all rows in this group from the current rows array + const groupRows = rows.filter( + (r): r is GroupedTableRow => + 'groupKey' in r && + r.groupKey === original.groupKey && + !r.isSummaryRow, + ); + + if (groupRows.length > 0) { + const firstRowInGroup = groupRows[0]!; + + // Check if this is the first row in the group + if (firstRowInGroup.id === original.id) { + isFirstRowInGroup = true; + } else { + isMuted = true; + } + } + } + + const originalRow = row.original as ExpandableTableRow | TableRow; + const isGroupHeader = + 'isGroupHeader' in originalRow && originalRow.isGroupHeader === true; + const isExpanded = grouped ? (row.getIsExpanded?.() ?? false) : false; + const isSerieGroupHeader = + isGroupHeader && + 'groupLevel' in originalRow && + originalRow.groupLevel === -1; + const hasSubRows = + 'subRows' in originalRow && (originalRow.subRows?.length ?? 0) > 0; + const isExpandable = grouped && isSerieGroupHeader && hasSubRows; + + return ( +
+ toggleSerieVisibility(serieId)} + style={{ + borderColor: color, + backgroundColor: isVisible ? color : 'transparent', + }} + className="h-4 w-4 shrink-0" + /> + + {isExpandable && ( + + )} +
+ ); + }, + }); + + // Breakdown columns (pinned left, collapsible) + breakdownPropertyNames.forEach((propertyName, index) => { + const isLastBreakdown = index === breakdownPropertyNames.length - 1; + const isCollapsible = grouped && !isLastBreakdown; + + cols.push({ + id: `breakdown-${index}`, + enableSorting: true, + enableResizing: true, + size: columnSizing[`breakdown-${index}`] ?? DEFAULT_COLUMN_WIDTH, + minSize: 100, + maxSize: 500, + accessorFn: (row) => { + if ('breakdownDisplay' in row && grouped) { + return row.breakdownDisplay[index] ?? ''; + } + return row.breakdownValues[index] ?? ''; + }, + header: ({ column }) => { + if (!isCollapsible) { + return propertyName; + } + + // Find all rows at this breakdown level that can be expanded + const rowsAtLevel: string[] = []; + if (grouped && expandableRows) { + function collectRowIdsAtLevel( + rows: ExpandableTableRow[], + targetLevel: number, + currentLevel = 0, + ): void { + for (const row of rows) { + if ( + row.isGroupHeader && + row.groupLevel === targetLevel && + (row.subRows?.length ?? 0) > 0 + ) { + rowsAtLevel.push(row.id); + } + // Recurse into subRows if we haven't reached target level yet + if (currentLevel < targetLevel && row.subRows) { + collectRowIdsAtLevel( + row.subRows, + targetLevel, + currentLevel + 1, + ); + } + } + } + collectRowIdsAtLevel(expandableRows, index); + } + + // Check if all groups at this level are expanded + const allExpanded = + rowsAtLevel.length > 0 && + rowsAtLevel.every( + (id) => typeof expanded === 'object' && expanded[id] === true, + ); + + return ( +
{ + if (!grouped) return; + // Toggle all groups at this breakdown level + setExpanded((prev) => { + const newExpanded: ExpandedState = + typeof prev === 'object' ? { ...prev } : {}; + const shouldExpand = !allExpanded; + rowsAtLevel.forEach((id) => { + newExpanded[id] = shouldExpand; + }); + return newExpanded; + }); + }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + if (!grouped) return; + setExpanded((prev) => { + const newExpanded: ExpandedState = + typeof prev === 'object' ? { ...prev } : {}; + const shouldExpand = !allExpanded; + rowsAtLevel.forEach((id) => { + newExpanded[id] = shouldExpand; + }); + return newExpanded; + }); + } + }} + role="button" + tabIndex={0} + > + {propertyName} +
+ ); + }, + meta: { + pinned: 'left', + isBreakdown: true, + }, + cell: ({ row }) => { + const original = row.original as ExpandableTableRow | TableRow; + const isGroupHeader = + 'isGroupHeader' in original && original.isGroupHeader === true; + const canExpand = row.getCanExpand?.() ?? false; + const isExpanded = row.getIsExpanded?.() ?? false; + + const value: string | number | null = + original.breakdownValues[index] ?? null; + const isLastBreakdown = index === breakdownPropertyNames.length - 1; + const isMuted = (!isLastBreakdown && !canExpand && grouped) || !value; + + // For group headers, only show value at the group level, hide deeper breakdowns + if (isGroupHeader && 'groupLevel' in original) { + const groupLevel = original.groupLevel ?? 0; + if (index !== groupLevel) { + return
; + } + } + + return ( +
+ + {value || '(Not set)'} + + {canExpand && + index === + ('groupLevel' in original ? (original.groupLevel ?? 0) : 0) && + index < breakdownPropertyNames.length - 1 && ( + + )} +
+ ); + }, + }); + }); + + // Metric columns + const metrics = [ + { key: 'count', label: 'Unique' }, + { key: 'sum', label: 'Sum' }, + { key: 'average', label: 'Average' }, + { key: 'min', label: 'Min' }, + { key: 'max', label: 'Max' }, + ] as const; + + metrics.forEach((metric) => { + cols.push({ + id: `metric-${metric.key}`, + header: metric.label, + accessorKey: metric.key, + enableSorting: true, + size: 100, + cell: ({ row }) => { + const value = row.original[metric.key]; + const original = row.original as ExpandableTableRow | TableRow; + const hasIsSummaryRow = 'isSummaryRow' in original; + const hasIsGroupHeader = 'isGroupHeader' in original; + const isSummary = hasIsSummaryRow && original.isSummaryRow === true; + const isGroupHeader = + hasIsGroupHeader && original.isGroupHeader === true; + const isIndividualRow = !isSummary && !isGroupHeader; + const range = metricRanges[metric.key]; + + // Only apply colors to individual rows, not summary or group header rows + // Also check that range is valid (not still at initial values) + const hasValidRange = + range && + range.min !== Number.POSITIVE_INFINITY && + range.max !== Number.NEGATIVE_INFINITY; + + const { style: backgroundStyle, opacity: bgOpacity } = + isIndividualRow && hasValidRange + ? getCellBackgroundStyle(value, range.min, range.max, 'purple') + : { style: {}, opacity: 0 }; + + return ( +
+ {number.format(value)} +
+ ); + }, + }); + }); + + // Date columns + dates.forEach((date) => { + cols.push({ + id: `date-${date}`, + header: formatDate(date), + accessorFn: (row) => row.dateValues[date] ?? 0, + enableSorting: true, + size: 100, + cell: ({ row }) => { + const value = row.original.dateValues[date] ?? 0; + const isSummary = row.original.isSummaryRow ?? false; + const isGroupHeader = + 'isGroupHeader' in row.original && + row.original.isGroupHeader === true; + const isIndividualRow = !isSummary && !isGroupHeader; + const range = dateRanges[date]; + // Only apply colors to individual rows, not summary or group header rows + // Also check that range is valid (not still at initial values) + const hasValidRange = + range && + range.min !== Number.POSITIVE_INFINITY && + range.max !== Number.NEGATIVE_INFINITY; + const { style: backgroundStyle, opacity: bgOpacity } = + isIndividualRow && hasValidRange + ? getCellBackgroundStyle(value, range.min, range.max, 'emerald') + : { style: {}, opacity: 0 }; + + const needsLightText = bgOpacity > 0.7; + + return ( +
+ {number.format(value)} +
+ ); + }, + }); + }); + + return cols; + }, [ + breakdownPropertyNames, + dates, + formatDate, + number, + grouped, + visibleSeriesIds, + expandableRows, + rows, + metricRanges, + dateRanges, + columnSizing, + expanded, + ]); + + // Create a hash of column IDs to track when columns change + const columnsHash = useMemo(() => { + return columns.map((col) => col.id).join(','); + }, [columns]); + + // Memoize table options to ensure table updates when filteredRows changes + const tableOptions = useMemo( + () => ({ + data: filteredRows, // This is already sorted in filteredRows + columns, + getCoreRowModel: getCoreRowModel(), + getExpandedRowModel: grouped ? getExpandedRowModel() : undefined, + getSubRows: grouped + ? (row: ExpandableTableRow | TableRow) => + 'subRows' in row ? row.subRows : undefined + : undefined, + // Sorting is handled manually in filteredRows, so we don't use getSortedRowModel + getFilteredRowModel: getFilteredRowModel(), + filterFns: { + isWithinRange: () => true, + }, + enableColumnResizing: true, + columnResizeMode: 'onChange' as const, + getRowCanExpand: grouped + ? (row: any) => { + const r = row.original as ExpandableTableRow; + if (!('isGroupHeader' in r) || !r.isGroupHeader) return false; + // Don't allow expansion for the last breakdown level + const groupLevel = r.groupLevel ?? -1; + const isLastBreakdown = + groupLevel === breakdownPropertyNames.length - 1; + const hasSubRows = (r.subRows?.length ?? 0) > 0; + return !isLastBreakdown && hasSubRows; + } + : undefined, + state: { + sorting, // Keep sorting state for UI indicators + columnSizing, + expanded: grouped ? expanded : undefined, + }, + onSortingChange: setSorting, + onColumnSizingChange: setColumnSizing, + onExpandedChange: grouped ? setExpanded : undefined, + globalFilterFn: () => true, // We handle filtering manually + manualSorting: true, // We handle sorting manually for both modes + manualFiltering: true, // We handle filtering manually + }), + [ + filteredRows, + columns, + grouped, + breakdownPropertyNames.length, + sorting, + columnSizing, + expanded, + setSorting, + setColumnSizing, + setExpanded, + ], + ); + + const table = useReactTable(tableOptions); + + // Virtualization setup + useEffect(() => { + const updateScrollMargin = throttle(() => { + if (parentRef.current) { + setScrollMargin( + parentRef.current.getBoundingClientRect().top + window.scrollY, + ); + } + }, 500); + + updateScrollMargin(); + window.addEventListener('resize', updateScrollMargin); + + return () => { + window.removeEventListener('resize', updateScrollMargin); + }; + }, []); + + // Handle global mouseup to reset resize flag + useEffect(() => { + const handleMouseUp = () => { + if (isResizingRef.current) { + // Small delay to ensure resize handlers complete + setTimeout(() => { + isResizingRef.current = false; + setResizingColumnId(null); + }, 100); + } + }; + + window.addEventListener('mouseup', handleMouseUp); + window.addEventListener('touchend', handleMouseUp); + + return () => { + window.removeEventListener('mouseup', handleMouseUp); + window.removeEventListener('touchend', handleMouseUp); + }; + }, []); + + // Get the row model to use (expanded when grouped, regular otherwise) + // filteredRows is already sorted, so getExpandedRowModel/getRowModel should preserve that order + // We need to recalculate when filteredRows changes to ensure sorting is applied + const rowModelToUse = useMemo(() => { + if (grouped) { + return table.getExpandedRowModel(); + } + return table.getRowModel(); + }, [table, grouped, expanded, filteredRows.length, sorting]); + + const virtualizer = useWindowVirtualizer({ + count: rowModelToUse.rows.length, + estimateSize: () => ROW_HEIGHT, + overscan: 10, + scrollMargin, + }); + + const virtualRows = virtualizer.getVirtualItems(); + + // Get visible columns in order + const headerColumns = table + .getAllLeafColumns() + .filter((col) => table.getState().columnVisibility[col.id] !== false); + + // Separate columns into pinned and scrollable + const leftPinnedColumns = headerColumns.filter( + (col) => col.columnDef.meta?.pinned === 'left', + ); + const rightPinnedColumns = headerColumns.filter( + (col) => col.columnDef.meta?.pinned === 'right', + ); + const scrollableColumns = headerColumns.filter( + (col) => !col.columnDef.meta?.pinned, + ); + + // Calculate widths for virtualization + const leftPinnedWidth = useMemo( + () => leftPinnedColumns.reduce((sum, col) => sum + col.getSize(), 0), + [leftPinnedColumns, columnSizing], + ); + const rightPinnedWidth = useMemo( + () => rightPinnedColumns.reduce((sum, col) => sum + col.getSize(), 0), + [rightPinnedColumns, columnSizing], + ); + const scrollableColumnsTotalWidth = useMemo( + () => scrollableColumns.reduce((sum, col) => sum + col.getSize(), 0), + [scrollableColumns, columnSizing], + ); + + // Horizontal virtualization for scrollable columns + // Only virtualize if we have enough columns to benefit from it + const shouldVirtualizeHorizontal = scrollableColumns.length > 10; + + const horizontalVirtualizer = useVirtualizer({ + count: scrollableColumns.length, + getScrollElement: () => parentRef.current, + estimateSize: (index) => + scrollableColumns[index]?.getSize() ?? DEFAULT_COLUMN_WIDTH, + horizontal: true, + overscan: shouldVirtualizeHorizontal ? 5 : scrollableColumns.length, + }); + + // Get virtual columns - if not virtualizing, return all columns + const virtualColumns = shouldVirtualizeHorizontal + ? horizontalVirtualizer.getVirtualItems() + : scrollableColumns.map((col, index) => ({ + index, + start: scrollableColumns + .slice(0, index) + .reduce((sum, c) => sum + c.getSize(), 0), + size: col.getSize(), + key: col.id, + end: 0, + lane: 0, + })); + + // Pre-compute grid template columns string and headers + const { gridTemplateColumns, headers } = useMemo(() => { + const headerGroups = table.getHeaderGroups(); + const firstGroupHeaders = headerGroups[0]?.headers ?? []; + return { + gridTemplateColumns: + firstGroupHeaders.map((h) => `${h.getSize()}px`).join(' ') ?? '', + headers: firstGroupHeaders, + }; + }, [table, columnSizing, columnsHash]); + + // Pre-compute pinning styles for all columns + const pinningStylesMap = useMemo(() => { + const stylesMap = new Map(); + const headerGroups = table.getHeaderGroups(); + + headerGroups.forEach((group) => { + group.headers.forEach((header) => { + const column = header.column; + const isPinned = column.columnDef.meta?.pinned; + if (!isPinned) { + stylesMap.set(column.id, {}); + return; + } + + const pinnedColumns = + isPinned === 'left' ? leftPinnedColumns : rightPinnedColumns; + const columnIndex = pinnedColumns.findIndex((c) => c.id === column.id); + const isLastPinned = + columnIndex === pinnedColumns.length - 1 && isPinned === 'left'; + const isFirstRightPinned = columnIndex === 0 && isPinned === 'right'; + + let left = 0; + if (isPinned === 'left') { + for (let i = 0; i < columnIndex; i++) { + left += pinnedColumns[i]!.getSize(); + } + } + + stylesMap.set(column.id, { + position: 'sticky' as const, + left: isPinned === 'left' ? `${left}px` : undefined, + right: isPinned === 'right' ? '0px' : undefined, + zIndex: 10, + backgroundColor: 'var(--card)', + boxShadow: isLastPinned + ? '-4px 0 4px -4px var(--border) inset' + : isFirstRightPinned + ? '4px 0 4px -4px var(--border) inset' + : undefined, + }); + }); + }); + + return stylesMap; + }, [table, leftPinnedColumns, rightPinnedColumns, columnSizing, columnsHash]); + + // Helper to get pinning styles (for backward compatibility with header) + const getPinningStyles = ( + column: ReturnType | undefined, + ) => { + if (!column) return {}; + return pinningStylesMap.get(column.id) ?? {}; + }; + + if (rows.length === 0) { + return null; } return ( - <> - - - - - - -
- - - - {breakdowns.length === 0 && Name} - {breakdowns.map((breakdown) => ( - - {getPropertyLabel(breakdown.name)} - - ))} - - - - {paginate(data.series).map((serie, index) => { - const checked = !!visibleSeries.find( - (item) => item.id === serie.id, - ); +
+ setGrouped(!grouped) + } + search={globalFilter} + onSearchChange={setGlobalFilter} + onUnselectAll={() => setVisibleSeries([])} + /> +
+
+ {/* Header */} +
+ {/* Left Pinned Columns */} + {leftPinnedColumns.map((column) => { + const header = headers.find((h) => h.column.id === column.id); + if (!header) return null; + const headerContent = column.columnDef.header; + const isBreakdown = column.columnDef.meta?.isBreakdown ?? false; + const pinningStyles = getPinningStyles(column); + const isMetricOrDate = + column.id.startsWith('metric-') || + column.id.startsWith('date-'); + + const canSort = column.getCanSort(); + const isSorted = column.getIsSorted(); + const canResize = column.getCanResize(); + const isPinned = column.columnDef.meta?.pinned === 'left'; return ( - - {serie.names.map((name, nameIndex) => { - return ( - -
- {nameIndex === 0 ? ( - <> - - handleChange(serie.id, !!checked) - } - style={ - checked - ? { - background: getChartColor(index), - borderColor: getChartColor(index), - } - : undefined - } - checked={checked} - /> - } - > - {name} - - - ) : ( - - )} -
-
- ); - })} -
+
{ + // Don't trigger sort if clicking on resize handle or if we just finished resizing + if ( + isResizingRef.current || + column.getIsResizing() || + (e.target as HTMLElement).closest( + '[data-resize-handle]', + ) + ) { + return; + } + column.toggleSorting(); + } + : undefined + } + onKeyDown={ + canSort + ? (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + column.toggleSorting(); + } + } + : undefined + } + role={canSort ? 'button' : undefined} + tabIndex={canSort ? 0 : undefined} + > +
+ {header.isPlaceholder + ? null + : typeof headerContent === 'function' + ? flexRender(headerContent, header.getContext()) + : headerContent} + {canSort && ( + + {isSorted === 'asc' + ? '↑' + : isSorted === 'desc' + ? '↓' + : '⇅'} + + )} +
+ {canResize && isPinned && ( +
{ + e.stopPropagation(); + isResizingRef.current = true; + setResizingColumnId(column.id); + header.getResizeHandler()(e); + }} + onMouseUp={() => { + // Use setTimeout to allow the resize to complete before resetting + setTimeout(() => { + isResizingRef.current = false; + setResizingColumnId(null); + }, 0); + }} + onTouchStart={(e) => { + e.stopPropagation(); + isResizingRef.current = true; + setResizingColumnId(column.id); + header.getResizeHandler()(e); + }} + onTouchEnd={() => { + setTimeout(() => { + isResizingRef.current = false; + setResizingColumnId(null); + }, 0); + }} + className={cn( + 'absolute right-0 top-0 h-full w-1 cursor-col-resize touch-none select-none bg-transparent hover:bg-primary/50 transition-colors', + header.column.getIsResizing() && 'bg-primary', + )} + /> + )} +
); })} - -
-
- - - - Total - Average - {data.series[0]?.data.map((serie) => ( - - {formatDate(serie.date)} - - ))} - - - - {paginate(data.series).map((serie) => { - return ( - - -
- {number.format(serie.metrics.sum)} - -
-
- -
- {number.format(serie.metrics.average)} - -
-
- {serie.data.map((item) => { - return ( - -
- {number.format(item.count)} - -
-
- ); - })} -
+ {/* Scrollable Columns (Virtualized) */} +
+ {virtualColumns.map((virtualCol) => { + const column = scrollableColumns[virtualCol.index]; + if (!column) return null; + const header = headers.find((h) => h.column.id === column.id); + if (!header) return null; + + const headerContent = header.column.columnDef.header; + const isBreakdown = + header.column.columnDef.meta?.isBreakdown ?? false; + const isMetricOrDate = + header.column.id.startsWith('metric-') || + header.column.id.startsWith('date-'); + const canSort = header.column.getCanSort(); + const isSorted = header.column.getIsSorted(); + + return ( +
{ + if ( + isResizingRef.current || + header.column.getIsResizing() || + (e.target as HTMLElement).closest( + '[data-resize-handle]', + ) + ) { + return; + } + header.column.toggleSorting(); + } + : undefined + } + onKeyDown={ + canSort + ? (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + header.column.toggleSorting(); + } + } + : undefined + } + role={canSort ? 'button' : undefined} + tabIndex={canSort ? 0 : undefined} + > +
+ {header.isPlaceholder + ? null + : typeof headerContent === 'function' + ? flexRender(headerContent, header.getContext()) + : headerContent} + {canSort && ( + + {isSorted === 'asc' + ? '↑' + : isSorted === 'desc' + ? '↓' + : '⇅'} + + )} +
+
); })} - -
+
+ + {/* Right Pinned Columns */} + {rightPinnedColumns.map((column) => { + const header = headers.find((h) => h.column.id === column.id); + if (!header) return null; + + const headerContent = header.column.columnDef.header; + const isBreakdown = + header.column.columnDef.meta?.isBreakdown ?? false; + const pinningStyles = getPinningStyles(header.column); + const isMetricOrDate = + header.column.id.startsWith('metric-') || + header.column.id.startsWith('date-'); + const canSort = header.column.getCanSort(); + const isSorted = header.column.getIsSorted(); + const canResize = header.column.getCanResize(); + + return ( +
{ + if ( + isResizingRef.current || + header.column.getIsResizing() || + (e.target as HTMLElement).closest( + '[data-resize-handle]', + ) + ) { + return; + } + header.column.toggleSorting(); + } + : undefined + } + onKeyDown={ + canSort + ? (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + header.column.toggleSorting(); + } + } + : undefined + } + role={canSort ? 'button' : undefined} + tabIndex={canSort ? 0 : undefined} + > +
+ {header.isPlaceholder + ? null + : typeof headerContent === 'function' + ? flexRender(headerContent, header.getContext()) + : headerContent} + {canSort && ( + + {isSorted === 'asc' + ? '↑' + : isSorted === 'desc' + ? '↓' + : '⇅'} + + )} +
+
+ ); + })} +
+ + {/* Virtualized Body */} +
+ {virtualRows.map((virtualRow) => { + const tableRow = rowModelToUse.rows[virtualRow.index]; + if (!tableRow) return null; + + return ( + + ); + })} +
- - {/*
- -
*/} - +
); } diff --git a/apps/start/src/components/report-chart/conversion/chart.tsx b/apps/start/src/components/report-chart/conversion/chart.tsx index 4237a351..0506fb92 100644 --- a/apps/start/src/components/report-chart/conversion/chart.tsx +++ b/apps/start/src/components/report-chart/conversion/chart.tsx @@ -2,9 +2,10 @@ import { pushModal } from '@/modals'; import type { RouterOutputs } from '@/trpc/client'; import { cn } from '@/utils/cn'; import { getChartColor } from '@/utils/theme'; -import { useCallback } from 'react'; +import React, { useCallback } from 'react'; import { CartesianGrid, + Legend, Line, LineChart, ReferenceLine, @@ -13,16 +14,25 @@ import { YAxis, } from 'recharts'; -import { createChartTooltip } from '@/components/charts/chart-tooltip'; +import { + ChartTooltipHeader, + ChartTooltipItem, + createChartTooltip, +} from '@/components/charts/chart-tooltip'; +import { useConversionRechartDataModel } from '@/hooks/use-conversion-rechart-data-model'; import { useFormatDateInterval } from '@/hooks/use-format-date-interval'; import { useNumber } from '@/hooks/use-numer-formatter'; +import { useVisibleConversionSeries } from '@/hooks/use-visible-conversion-series'; import { useTRPC } from '@/integrations/trpc/react'; import { average, getPreviousMetric, round } from '@openpanel/common'; import type { IInterval } from '@openpanel/validation'; import { useQuery } from '@tanstack/react-query'; import { useXAxisProps, useYAxisProps } from '../common/axis'; -import { PreviousDiffIndicatorPure } from '../common/previous-diff-indicator'; +import { PreviousDiffIndicator } from '../common/previous-diff-indicator'; +import { SerieIcon } from '../common/serie-icon'; +import { SerieName } from '../common/serie-name'; import { useReportChartContext } from '../context'; +import { ConversionTable } from './conversion-table'; interface Props { data: RouterOutputs['chart']['conversion']; @@ -30,20 +40,12 @@ interface Props { export function Chart({ data }: Props) { const { - report: { - previous, - interval, - projectId, - startDate, - endDate, - range, - lineType, - events, - }, + report: { interval, projectId, startDate, endDate, range, lineType }, isEditMode, options: { hideXAxis, hideYAxis, maxDomain }, } = useReportChartContext(); - const dataLength = data.current.length || 0; + const { series, setVisibleSeries } = useVisibleConversionSeries(data, 5); + const rechartData = useConversionRechartDataModel(series); const trpc = useTRPC(); const references = useQuery( trpc.reference.getChartReferences.queryOptions( @@ -65,18 +67,11 @@ export function Chart({ data }: Props) { }); const averageConversionRate = average( - data.current.map((serie) => { + series.map((serie) => { return average(serie.data.map((item) => item.rate)); }, 0), ); - const rechartData = data.current[0].data.map((item) => { - return { - ...item, - timestamp: new Date(item.date).getTime(), - }; - }); - const handleChartClick = useCallback((e: any) => { if (e?.activePayload?.[0]) { const clickedData = e.activePayload[0].payload; @@ -88,8 +83,36 @@ export function Chart({ data }: Props) { } }, []); + const CustomLegend = useCallback(() => { + return ( +
+ {series.map((serie) => ( +
+ + 0 ? serie.breakdowns : ['Conversion'] + } + className="font-semibold" + /> +
+ ))} +
+ ); + }, [series]); + return ( - +
@@ -116,35 +139,48 @@ export function Chart({ data }: Props) { ))} + {series.length > 1 && } />} - - + {series.map((serie) => { + const color = getChartColor(serie.index); + return ( + + ); + })} + {series.map((serie) => { + const color = getChartColor(serie.index); + return ( + + ); + })} {typeof averageConversionRate === 'number' && averageConversionRate && (
+
); } const { Tooltip, TooltipProvider } = createChartTooltip< - NonNullable< - RouterOutputs['chart']['conversion']['current'][number] - >['data'][number], + Record, { conversion: RouterOutputs['chart']['conversion']; interval: IInterval; + visibleSeries: RouterOutputs['chart']['conversion']['current']; } >(({ data, context }) => { - if (!data[0]) { + if (!data || !data[0]) { return null; } - const { date } = data[0]; + const payload = data[0]; + const { date } = payload; const formatDate = useFormatDateInterval({ interval: context.interval, short: false, }); const number = useNumber(); + return ( <> -
-
{formatDate(date)}
-
- {context.conversion.current.map((serie, index) => { - const item = data[index]; - if (!item) { + {context.visibleSeries.map((serie, index) => { + const rate = payload[`${serie.id}:rate`]; + const total = payload[`${serie.id}:total`]; + const previousRate = payload[`${serie.id}:previousRate`]; + + if (rate === undefined) { return null; } - const prevItem = context.conversion?.previous?.[0]?.data[item.index]; - const title = - serie.breakdowns.length > 0 - ? (serie.breakdowns.join(',') ?? 'Not set') - : 'Conversion'; + const prevSerie = context.conversion?.previous?.find( + (p) => p.id === serie.id, + ); + const prevItem = prevSerie?.data.find((d) => d.date === date); + const previousMetric = getPreviousMetric(rate, previousRate); + return ( -
-
-
-
{title}
+ + {index === 0 && ( + +
{formatDate(date)}
+
+ )} + +
+ 0 + ? serie.breakdowns + : ['Conversion'] + } + /> + 0 + ? serie.breakdowns + : ['Conversion'] + } + /> +
-
- {number.formatWithUnit(item.rate / 100, '%')} - {item.total} -
- - {!!prevItem && ( -
- +
+ {number.formatWithUnit(rate / 100, '%')} + ({total}) + {prevItem && previousRate !== undefined && ( - ({prevItem?.total}) + ({number.formatWithUnit(previousRate / 100, '%')}) -
+ )} +
+ {previousRate !== undefined && ( + )}
-
-
+ + ); })} diff --git a/apps/start/src/components/report-chart/conversion/conversion-table.tsx b/apps/start/src/components/report-chart/conversion/conversion-table.tsx new file mode 100644 index 00000000..5242dd1e --- /dev/null +++ b/apps/start/src/components/report-chart/conversion/conversion-table.tsx @@ -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>; +} + +export function ConversionTable({ + data, + visibleSeries, + setVisibleSeries, +}: ConversionTableProps) { + const [sorting, setSorting] = useState([]); + 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 = {}; + dates.forEach((date) => { + const item = serie.data.find((d) => d.date === date); + dateValues[date] = item?.rate ?? 0; + }); + + const total = serie.data.reduce((sum, item) => sum + item.total, 0); + const conversions = serie.data.reduce( + (sum, item) => sum + item.conversions, + 0, + ); + const avgRate = + serie.data.length > 0 + ? serie.data.reduce((sum, item) => sum + item.rate, 0) / + serie.data.length + : 0; + + const prevSerie = data.previous?.find((p) => p.id === serie.id); + const prevAvgRate = + prevSerie && prevSerie.data.length > 0 + ? prevSerie.data.reduce((sum, item) => sum + item.rate, 0) / + prevSerie.data.length + : undefined; + + return { + id: serie.id, + serieId: serie.id, + serieName: + serie.breakdowns.length > 0 ? serie.breakdowns : ['Conversion'], + breakdownValues: serie.breakdowns, + avgRate, + prevAvgRate, + total, + conversions, + dateValues, + }; + }); + }, [allSeries, dates, data.previous]); + + // Calculate ranges for color visualization (memoized) + const { metricRanges, dateRanges } = useMemo(() => { + const metricRanges: Record = { + avgRate: { + min: Number.POSITIVE_INFINITY, + max: Number.NEGATIVE_INFINITY, + }, + total: { + min: Number.POSITIVE_INFINITY, + max: Number.NEGATIVE_INFINITY, + }, + conversions: { + min: Number.POSITIVE_INFINITY, + max: Number.NEGATIVE_INFINITY, + }, + }; + + const dateRanges: Record = {}; + dates.forEach((date) => { + dateRanges[date] = { + min: Number.POSITIVE_INFINITY, + max: Number.NEGATIVE_INFINITY, + }; + }); + + rows.forEach((row) => { + // Metric ranges + metricRanges.avgRate.min = Math.min( + metricRanges.avgRate.min, + row.avgRate, + ); + metricRanges.avgRate.max = Math.max( + metricRanges.avgRate.max, + row.avgRate, + ); + metricRanges.total.min = Math.min(metricRanges.total.min, row.total); + metricRanges.total.max = Math.max(metricRanges.total.max, row.total); + metricRanges.conversions.min = Math.min( + metricRanges.conversions.min, + row.conversions, + ); + metricRanges.conversions.max = Math.max( + metricRanges.conversions.max, + row.conversions, + ); + + // Date ranges + dates.forEach((date) => { + const value = row.dateValues[date] ?? 0; + dateRanges[date]!.min = Math.min(dateRanges[date]!.min, value); + dateRanges[date]!.max = Math.max(dateRanges[date]!.max, value); + }); + }); + + return { metricRanges, dateRanges }; + }, [rows, dates]); + + // Helper to get background color style + const getCellBackgroundStyle = ( + value: number, + min: number, + max: number, + colorClass: 'purple' | 'emerald' = 'emerald', + ): React.CSSProperties => { + if (value === 0 || max === min) { + return {}; + } + + const percentage = (value - min) / (max - min); + const opacity = Math.max(0.05, Math.min(1, percentage)); + + const backgroundColor = + colorClass === 'purple' + ? `rgba(168, 85, 247, ${opacity})` + : `rgba(16, 185, 129, ${opacity})`; + + return { backgroundColor }; + }; + + const visibleSeriesIds = useMemo( + () => visibleSeries.map((s) => s.id), + [visibleSeries], + ); + + const getSerieIndex = (serieId: string): number => { + return allSeries.findIndex((s) => s.id === serieId); + }; + + const toggleSerieVisibility = (serieId: string) => { + setVisibleSeries((prev) => { + if (prev.includes(serieId)) { + return prev.filter((id) => id !== serieId); + } + return [...prev, serieId]; + }); + }; + + // 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 ( +
+ setVisibleSeries([])} + /> +
+ + + + + + + + {dates.map((date) => ( + + ))} + + + + {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 ( + + + + + + {dates.map((date) => { + const value = row.dateValues[date] ?? 0; + return ( + + ); + })} + + ); + })} + +
+
Serie
+
handleSort('metric-avgRate')} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleSort('metric-avgRate'); + } + }} + > +
+ Avg Rate + + {getSortIcon('metric-avgRate')} + +
+
handleSort('metric-total')} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleSort('metric-total'); + } + }} + > +
+ Total + + {getSortIcon('metric-total')} + +
+
handleSort('metric-conversions')} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleSort('metric-conversions'); + } + }} + > +
+ Conversions + + {getSortIcon('metric-conversions')} + +
+
handleSort(`date-${date}`)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleSort(`date-${date}`); + } + }} + > +
+ {formatDate(date)} + + {getSortIcon(`date-${date}`)} + +
+
+
+ + toggleSerieVisibility(row.serieId) + } + style={{ + borderColor: color, + backgroundColor: isVisible ? color : 'transparent', + }} + className="h-4 w-4 shrink-0" + /> +
+ + +
+
+
+ + {number.formatWithUnit(row.avgRate / 100, '%')} + + {previousMetric && ( + + )} +
+
+ {number.format(row.total)} + + {number.format(row.conversions)} + + {number.formatWithUnit(value / 100, '%')} +
+
+
+ ); +} diff --git a/apps/start/src/components/report-chart/conversion/index.tsx b/apps/start/src/components/report-chart/conversion/index.tsx index f46a89bd..fc75527b 100644 --- a/apps/start/src/components/report-chart/conversion/index.tsx +++ b/apps/start/src/components/report-chart/conversion/index.tsx @@ -13,7 +13,7 @@ import { Summary } from './summary'; export function ReportConversionChart() { const { isLazyLoading, report } = useReportChartContext(); const trpc = useTRPC(); - + console.log(report.limit); const res = useQuery( trpc.chart.conversion.queryOptions(report, { placeholderData: keepPreviousData, diff --git a/apps/start/src/components/report-chart/conversion/summary.tsx b/apps/start/src/components/report-chart/conversion/summary.tsx index 683d8fc1..60dc97de 100644 --- a/apps/start/src/components/report-chart/conversion/summary.tsx +++ b/apps/start/src/components/report-chart/conversion/summary.tsx @@ -144,14 +144,16 @@ export function Summary({ data }: Props) { title="Flow" value={
- {report.events.map((event, index) => { - return ( -
- {index !== 0 && } - {event.name} -
- ); - })} + {report.series + .filter((item) => item.type === 'event') + .map((event, index) => { + return ( +
+ {index !== 0 && } + {event.name} +
+ ); + })}
} /> diff --git a/apps/start/src/components/report-chart/funnel/chart.tsx b/apps/start/src/components/report-chart/funnel/chart.tsx index 96d0fff0..e48fce13 100644 --- a/apps/start/src/components/report-chart/funnel/chart.tsx +++ b/apps/start/src/components/report-chart/funnel/chart.tsx @@ -1,7 +1,9 @@ import { ColorSquare } from '@/components/color-square'; +import { Button } from '@/components/ui/button'; +import { pushModal } from '@/modals'; import type { RouterOutputs } from '@/trpc/client'; import { cn } from '@/utils/cn'; -import { ChevronRightIcon, InfoIcon } from 'lucide-react'; +import { ChevronRightIcon, InfoIcon, UsersIcon } from 'lucide-react'; import { alphabetIds } from '@openpanel/constants'; @@ -23,6 +25,7 @@ import { } from 'recharts'; import { useXAxisProps, useYAxisProps } from '../common/axis'; import { PreviousDiffIndicatorPure } from '../common/previous-diff-indicator'; +import { useReportChartContext } from '../context'; type Props = { data: { @@ -113,11 +116,50 @@ function ChartName({ export function Tables({ data: { current: { steps, mostDropoffsStep, lastStep, breakdowns }, - previous, + previous: previousData, }, }: Props) { const number = useNumber(); const hasHeader = breakdowns.length > 0; + const { + report: { + projectId, + startDate, + endDate, + range, + interval, + series: reportSeries, + breakdowns: reportBreakdowns, + previous, + funnelWindow, + funnelGroup, + }, + } = useReportChartContext(); + + const handleInspectStep = (step: (typeof steps)[0], stepIndex: number) => { + if (!projectId || !step.event.id) return; + + // For funnels, we need to pass the step index so the modal can query + // users who completed at least that step in the funnel sequence + pushModal('ViewChartUsers', { + type: 'funnel', + report: { + projectId, + series: reportSeries, + breakdowns: reportBreakdowns || [], + interval: interval || 'day', + startDate, + endDate, + range, + previous, + chartType: 'funnel', + metric: 'sum', + funnelWindow, + funnelGroup, + }, + stepIndex, // Pass the step index for funnel queries + }); + }; return (
{hasHeader && } @@ -128,11 +170,11 @@ export function Tables({ label="Conversion" value={number.formatWithUnit(lastStep?.percent / 100, '%')} enhancer={ - previous && ( + previousData && ( ) @@ -143,11 +185,11 @@ export function Tables({ label="Completed" value={number.format(lastStep?.count)} enhancer={ - previous && ( + previousData && ( ) @@ -238,6 +280,28 @@ export function Tables({ className: 'text-right font-mono font-semibold', width: '90px', }, + { + name: '', + render: (item) => ( + + ), + className: 'text-right', + width: '48px', + }, ]} />
@@ -299,6 +363,7 @@ export function Chart({ data }: { data: RouterOutputs['chart']['funnel'] }) { const rechartData = useRechartData(data); const xAxisProps = useXAxisProps(); const yAxisProps = useYAxisProps(); + const hasBreakdowns = data.current.length > 1; return ( @@ -327,19 +392,37 @@ export function Chart({ data }: { data: RouterOutputs['chart']['funnel'] }) { } /> - } - > - {rechartData.map((item, index) => ( - - ))} - + {hasBreakdowns ? ( + data.current.map((item, breakdownIndex) => ( + } + > + {rechartData.map((item, stepIndex) => ( + + ))} + + )) + ) : ( + } + > + {rechartData.map((item, index) => ( + + ))} + + )} @@ -348,8 +431,6 @@ export function Chart({ data }: { data: RouterOutputs['chart']['funnel'] }) { ); } -type Hej = RouterOutputs['chart']['funnel']['current']; - const { Tooltip, TooltipProvider } = createChartTooltip< RechartData, { @@ -371,7 +452,7 @@ const { Tooltip, TooltipProvider } = createChartTooltip<
{data.name}
- {variants.map((key) => { + {variants.map((key, breakdownIndex) => { const variant = data[key]; const prevVariant = data[`prev_${key}`]; if (!variant?.step) { @@ -381,7 +462,11 @@ const { Tooltip, TooltipProvider } = createChartTooltip<
1 ? breakdownIndex : index, + ), + }} />
diff --git a/apps/start/src/components/report-chart/funnel/index.tsx b/apps/start/src/components/report-chart/funnel/index.tsx index e80063cc..5fdda9c2 100644 --- a/apps/start/src/components/report-chart/funnel/index.tsx +++ b/apps/start/src/components/report-chart/funnel/index.tsx @@ -14,7 +14,7 @@ import { Chart, Summary, Tables } from './chart'; export function ReportFunnelChart() { const { report: { - events, + series, range, projectId, funnelWindow, @@ -28,7 +28,7 @@ export function ReportFunnelChart() { } = useReportChartContext(); const input: IChartInput = { - events, + series, range, projectId, interval: 'day', @@ -40,11 +40,12 @@ export function ReportFunnelChart() { metric: 'sum', startDate, endDate, + limit: 20, }; const trpc = useTRPC(); const res = useQuery( trpc.chart.funnel.queryOptions(input, { - enabled: !isLazyLoading && input.events.length > 0, + enabled: !isLazyLoading && input.series.length > 0, }), ); diff --git a/apps/start/src/components/report-chart/histogram/chart.tsx b/apps/start/src/components/report-chart/histogram/chart.tsx index 932de7f1..529ab1a2 100644 --- a/apps/start/src/components/report-chart/histogram/chart.tsx +++ b/apps/start/src/components/report-chart/histogram/chart.tsx @@ -7,6 +7,7 @@ import type { IChartData } from '@/trpc/client'; import { cn } from '@/utils/cn'; import { getChartColor } from '@/utils/theme'; import { useQuery } from '@tanstack/react-query'; +import { BookmarkIcon, UsersIcon } from 'lucide-react'; import React, { useCallback } from 'react'; import { Bar, @@ -20,6 +21,10 @@ import { } from 'recharts'; import { useXAxisProps, useYAxisProps } from '../common/axis'; +import { + ChartClickMenu, + type ChartClickMenuItem, +} from '../common/chart-click-menu'; import { ReportChartTooltip } from '../common/report-chart-tooltip'; import { ReportTable } from '../common/report-table'; import { useReportChartContext } from '../context'; @@ -47,7 +52,16 @@ function BarHover({ x, y, width, height, top, left, right, bottom }: any) { export function Chart({ data }: Props) { const { isEditMode, - report: { previous, interval, projectId, startDate, endDate, range }, + report: { + previous, + interval, + projectId, + startDate, + endDate, + range, + series: reportSeries, + breakdowns, + }, options: { hideXAxis, hideYAxis }, } = useReportChartContext(); const trpc = useTRPC(); @@ -74,22 +88,73 @@ export function Chart({ data }: Props) { interval, }); - const handleChartClick = useCallback((e: any) => { - if (e?.activePayload?.[0]) { - const clickedData = e.activePayload[0].payload; - if (clickedData.date) { - pushModal('AddReference', { - datetime: new Date(clickedData.date).toISOString(), + const getMenuItems = useCallback( + (e: any, clickedData: any): ChartClickMenuItem[] => { + const items: ChartClickMenuItem[] = []; + + if (!clickedData?.date) { + return items; + } + + // View Users - only show if we have projectId + if (projectId) { + items.push({ + label: 'View Users', + icon: , + 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: , + onClick: () => { + pushModal('AddReference', { + datetime: new Date(clickedData.date).toISOString(), + }); + }, + }); + + return items; + }, + [ + projectId, + data, + reportSeries, + breakdowns, + interval, + startDate, + endDate, + range, + previous, + ], + ); return ( -
- - + +
+ + )} + ); } diff --git a/apps/start/src/components/report-chart/line/chart.tsx b/apps/start/src/components/report-chart/line/chart.tsx index bd8e522b..d546258d 100644 --- a/apps/start/src/components/report-chart/line/chart.tsx +++ b/apps/start/src/components/report-chart/line/chart.tsx @@ -7,6 +7,7 @@ import { cn } from '@/utils/cn'; import { getChartColor } from '@/utils/theme'; import { useQuery } from '@tanstack/react-query'; import { isSameDay, isSameHour, isSameMonth, isSameWeek } from 'date-fns'; +import { BookmarkIcon, UsersIcon } from 'lucide-react'; import { last } from 'ramda'; import { useCallback } from 'react'; import { @@ -24,6 +25,10 @@ import { import { useDashedStroke } from '@/hooks/use-dashed-stroke'; import { useXAxisProps, useYAxisProps } from '../common/axis'; +import { + ChartClickMenu, + type ChartClickMenuItem, +} from '../common/chart-click-menu'; import { ReportChartTooltip } from '../common/report-chart-tooltip'; import { ReportTable } from '../common/report-table'; import { SerieIcon } from '../common/serie-icon'; @@ -44,6 +49,8 @@ export function Chart({ data }: Props) { endDate, range, lineType, + series: reportSeries, + breakdowns, }, isEditMode, options: { hideXAxis, hideYAxis, maxDomain }, @@ -128,81 +135,146 @@ export function Chart({ data }: Props) { hide: hideYAxis, }); - const handleChartClick = useCallback((e: any) => { - if (e?.activePayload?.[0]) { - const clickedData = e.activePayload[0].payload; - if (clickedData.date) { - pushModal('AddReference', { - datetime: new Date(clickedData.date).toISOString(), + const getMenuItems = useCallback( + (e: any, clickedData: any): ChartClickMenuItem[] => { + const items: ChartClickMenuItem[] = []; + + if (!clickedData?.date) { + return items; + } + + // Extract serie ID from the click event if needed + // activePayload is an array of payload objects + const validPayload = e.activePayload?.find( + (p: any) => + p.dataKey && + p.dataKey !== 'calcStrokeDasharray' && + typeof p.dataKey === 'string' && + p.dataKey.includes(':count'), + ); + const serieId = validPayload?.dataKey?.toString().replace(':count', ''); + + // View Users - only show if we have projectId + if (projectId) { + items.push({ + label: 'View Users', + icon: , + 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: , + onClick: () => { + pushModal('AddReference', { + datetime: new Date(clickedData.date).toISOString(), + }); + }, + }); + + return items; + }, + [ + projectId, + data, + reportSeries, + breakdowns, + interval, + startDate, + endDate, + range, + previous, + ], + ); return ( -
- - - - - - {references.data?.map((ref) => ( - +
+ + + + - ))} - - - {series.length > 1 && } />} - } /> - {/* {series.map((serie) => { - const color = getChartColor(serie.index); - return ( - - - {isAreaStyle && ( - - - - - )} - + + {references.data?.map((ref) => ( + + ))} + + + {series.length > 1 && } />} + } /> + + + + + + + + + + + + {series.map((serie) => { + const color = getChartColor(serie.index); + return ( - {previous && ( - - )} - - ); - })} */} + ); + })} - - - - - - - - - - - {series.map((serie) => { - const color = getChartColor(serie.index); - return ( - - ); - })} - - {/* Previous */} - {previous - ? series.map((serie) => { - const color = getChartColor(serie.index); - return ( - - ); - }) - : null} - - -
- {isEditMode && ( - - )} + {/* Previous */} + {previous + ? series.map((serie) => { + const color = getChartColor(serie.index); + return ( + + ); + }) + : null} +
+
+
+ {isEditMode && ( + + )} +
); } diff --git a/apps/start/src/components/report-chart/metric/metric-card.tsx b/apps/start/src/components/report-chart/metric/metric-card.tsx index 0fffd992..7383889f 100644 --- a/apps/start/src/components/report-chart/metric/metric-card.tsx +++ b/apps/start/src/components/report-chart/metric/metric-card.tsx @@ -89,7 +89,10 @@ export function MetricCard({ return (
item.type === 'event'); + const firstEvent = (eventSeries[0]?.filters?.[0]?.value ?? []).map(String); + const secondEvent = (eventSeries[1]?.filters?.[0]?.value ?? []).map(String); const isEnabled = firstEvent.length > 0 && secondEvent.length > 0 && !isLazyLoading; const trpc = useTRPC(); diff --git a/apps/start/src/components/report-chart/shortcut.tsx b/apps/start/src/components/report-chart/shortcut.tsx index bcf3cfd2..c42e6aa4 100644 --- a/apps/start/src/components/report-chart/shortcut.tsx +++ b/apps/start/src/components/report-chart/shortcut.tsx @@ -7,7 +7,7 @@ type ChartRootShortcutProps = Omit & { previous?: ReportChartProps['report']['previous']; chartType?: ReportChartProps['report']['chartType']; interval?: ReportChartProps['report']['interval']; - events: ReportChartProps['report']['events']; + series: ReportChartProps['report']['series']; breakdowns?: ReportChartProps['report']['breakdowns']; lineType?: ReportChartProps['report']['lineType']; }; @@ -18,7 +18,7 @@ export const ReportChartShortcut = ({ previous = false, chartType = 'linear', interval = 'day', - events, + series, breakdowns, lineType = 'monotone', options, @@ -33,7 +33,7 @@ export const ReportChartShortcut = ({ previous, chartType, interval, - events, + series, lineType, metric: 'sum', }} diff --git a/apps/start/src/components/report/reportSlice.ts b/apps/start/src/components/report/reportSlice.ts index 5a001a78..9ec30e32 100644 --- a/apps/start/src/components/report/reportSlice.ts +++ b/apps/start/src/components/report/reportSlice.ts @@ -11,12 +11,14 @@ import { } from '@openpanel/constants'; import type { IChartBreakdown, - IChartEvent, + IChartEventItem, + IChartFormula, IChartLineType, IChartProps, IChartRange, IChartType, IInterval, + UnionOmit, zCriteria, } from '@openpanel/validation'; import type { z } from 'zod'; @@ -39,7 +41,7 @@ const initialState: InitialState = { lineType: 'monotone', interval: 'day', breakdowns: [], - events: [], + series: [], range: '30d', startDate: null, endDate: null, @@ -86,24 +88,34 @@ export const reportSlice = createSlice({ state.dirty = true; state.name = action.payload; }, - // Events - addEvent: (state, action: PayloadAction>) => { + // Series (Events and Formulas) + addSerie: ( + state, + action: PayloadAction>, + ) => { state.dirty = true; - state.events.push({ + state.series.push({ id: shortId(), ...action.payload, }); }, - duplicateEvent: (state, action: PayloadAction>) => { + duplicateEvent: (state, action: PayloadAction) => { state.dirty = true; - state.events.push({ - ...action.payload, - filters: action.payload.filters.map((filter) => ({ - ...filter, + if (action.payload.type === 'event') { + state.series.push({ + ...action.payload, + filters: action.payload.filters.map((filter) => ({ + ...filter, + id: shortId(), + })), id: shortId(), - })), - id: shortId(), - }); + } as IChartEventItem); + } else { + state.series.push({ + ...action.payload, + id: shortId(), + } as IChartEventItem); + } }, removeEvent: ( state, @@ -112,13 +124,13 @@ export const reportSlice = createSlice({ }>, ) => { state.dirty = true; - state.events = state.events.filter( - (event) => event.id !== action.payload.id, - ); + state.series = state.series.filter((event) => { + return event.id !== action.payload.id; + }); }, - changeEvent: (state, action: PayloadAction) => { + changeEvent: (state, action: PayloadAction) => { state.dirty = true; - state.events = state.events.map((event) => { + state.series = state.series.map((event) => { if (event.id === action.payload.id) { return action.payload; } @@ -265,9 +277,9 @@ export const reportSlice = createSlice({ ) { state.dirty = true; const { fromIndex, toIndex } = action.payload; - const [movedEvent] = state.events.splice(fromIndex, 1); + const [movedEvent] = state.series.splice(fromIndex, 1); if (movedEvent) { - state.events.splice(toIndex, 0, movedEvent); + state.series.splice(toIndex, 0, movedEvent); } }, }, @@ -279,7 +291,7 @@ export const { ready, setReport, setName, - addEvent, + addSerie, removeEvent, duplicateEvent, changeEvent, diff --git a/apps/start/src/components/report/sidebar/EventPropertiesCombobox.tsx b/apps/start/src/components/report/sidebar/EventPropertiesCombobox.tsx index fe702087..059c47c7 100644 --- a/apps/start/src/components/report/sidebar/EventPropertiesCombobox.tsx +++ b/apps/start/src/components/report/sidebar/EventPropertiesCombobox.tsx @@ -1,8 +1,7 @@ import { Combobox } from '@/components/ui/combobox'; import { useAppParams } from '@/hooks/use-app-params'; import { useEventProperties } from '@/hooks/use-event-properties'; -import { useDispatch, useSelector } from '@/redux'; -import { api } from '@/trpc/client'; +import { useDispatch } from '@/redux'; import { cn } from '@/utils/cn'; import { DatabaseIcon } from 'lucide-react'; @@ -43,6 +42,7 @@ export function EventPropertiesCombobox({ changeEvent({ ...event, property: value, + type: 'event', }), ); }} diff --git a/apps/start/src/components/report/sidebar/ReportEvents.tsx b/apps/start/src/components/report/sidebar/ReportEvents.tsx index f24c52b8..6cb03682 100644 --- a/apps/start/src/components/report/sidebar/ReportEvents.tsx +++ b/apps/start/src/components/report/sidebar/ReportEvents.tsx @@ -1,6 +1,8 @@ import { ColorSquare } from '@/components/color-square'; +import { Button } from '@/components/ui/button'; import { ComboboxEvents } from '@/components/ui/combobox-events'; import { Input } from '@/components/ui/input'; +import { InputEnter } from '@/components/ui/input-enter'; import { useAppParams } from '@/hooks/use-app-params'; import { useDebounceFn } from '@/hooks/use-debounce-fn'; import { useEventNames } from '@/hooks/use-event-names'; @@ -23,11 +25,11 @@ import { import { CSS } from '@dnd-kit/utilities'; import { shortId } from '@openpanel/common'; import { alphabetIds } from '@openpanel/constants'; -import type { IChartEvent } from '@openpanel/validation'; -import { FilterIcon, HandIcon } from 'lucide-react'; +import type { IChartEventItem, IChartFormula } from '@openpanel/validation'; +import { FilterIcon, HandIcon, PlusIcon } from 'lucide-react'; import { ReportSegment } from '../ReportSegment'; import { - addEvent, + addSerie, changeEvent, duplicateEvent, removeEvent, @@ -47,7 +49,7 @@ function SortableEvent({ isSelectManyEvents, ...props }: { - event: IChartEvent; + event: IChartEventItem; index: number; showSegment: boolean; showAddFilter: boolean; @@ -62,6 +64,8 @@ function SortableEvent({ transition, }; + const isEvent = event.type === 'event'; + return (
@@ -76,8 +80,8 @@ function SortableEvent({ {props.children}
- {/* Segment and Filter buttons */} - {(showSegment || showAddFilter) && ( + {/* Segment and Filter buttons - only for events */} + {isEvent && (showSegment || showAddFilter) && (
{showSegment && ( )} - {/* Filters */} - {!isSelectManyEvents && } + {/* Filters - only for events */} + {isEvent && !isSelectManyEvents && }
); } export function ReportEvents() { - const selectedEvents = useSelector((state) => state.report.events); + const selectedEvents = useSelector((state) => state.report.series); const chartType = useSelector((state) => state.report.chartType); const dispatch = useDispatch(); const { projectId } = useAppParams(); @@ -151,7 +155,7 @@ export function ReportEvents() { const isAddEventDisabled = (chartType === 'retention' || chartType === 'conversion') && selectedEvents.length >= 2; - const dispatchChangeEvent = useDebounceFn((event: IChartEvent) => { + const dispatchChangeEvent = useDebounceFn((event: IChartEventItem) => { dispatch(changeEvent(event)); }); const isSelectManyEvents = chartType === 'retention'; @@ -174,11 +178,15 @@ export function ReportEvents() { } }; - const handleMore = (event: IChartEvent) => { + const handleMore = (event: IChartEventItem) => { const callback: ReportEventMoreProps['onClick'] = (action) => { switch (action) { case 'remove': { - return dispatch(removeEvent(event)); + return dispatch( + removeEvent({ + id: event.id, + }), + ); } case 'duplicate': { return dispatch(duplicateEvent(event)); @@ -189,20 +197,31 @@ export function ReportEvents() { return callback; }; + const dispatchChangeFormula = useDebounceFn((formula: IChartFormula) => { + dispatch(changeEvent(formula)); + }); + + const showFormula = + chartType !== 'conversion' && + chartType !== 'funnel' && + chartType !== 'retention'; + return (
-

Events

+

Metrics

({ id: e.id ?? '' }))} + items={selectedEvents.map((e) => ({ id: e.id! }))} strategy={verticalListSortingStrategy} >
{selectedEvents.map((event, index) => { + const isFormula = event.type === 'formula'; + return ( - { - dispatch( - changeEvent( - Array.isArray(value) - ? { - id: event.id, - segment: 'user', - filters: [ - { - name: 'name', - operator: 'is', - value: value, - }, - ], - name: '*', - } - : { + {isFormula ? ( + <> +
+ { + dispatchChangeFormula({ + ...event, + formula: value, + }); + }} + /> + {showDisplayNameInput && ( + { + dispatchChangeFormula({ ...event, - name: value, - filters: [], - }, - ), - ); - }} - items={eventNames} - placeholder="Select event" - /> - {showDisplayNameInput && ( - { - dispatchChangeEvent({ - ...event, - displayName: e.target.value, - }); - }} - /> + displayName: e.target.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 && ( + { + dispatchChangeEvent({ + ...event, + displayName: e.target.value, + }); + }} + /> + )} + + )} -
); })} - { - if (isSelectManyEvents) { - dispatch( - addEvent({ - segment: 'user', - name: value, - filters: [ - { - name: 'name', - operator: 'is', - value: [value], - }, - ], - }), - ); - } else { - dispatch( - addEvent({ - name: value, - segment: 'event', - filters: [], - }), - ); - } - }} - placeholder="Select event" - items={eventNames} - /> +
+ { + 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 && ( + + )} +
diff --git a/apps/start/src/components/report/sidebar/ReportFormula.tsx b/apps/start/src/components/report/sidebar/ReportFormula.tsx deleted file mode 100644 index a828de63..00000000 --- a/apps/start/src/components/report/sidebar/ReportFormula.tsx +++ /dev/null @@ -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 ( -
-

Formula

-
- { - dispatch(changeFormula(value)); - }} - /> -
-
- ); -} diff --git a/apps/start/src/components/report/sidebar/ReportSeries.tsx b/apps/start/src/components/report/sidebar/ReportSeries.tsx new file mode 100644 index 00000000..216573b4 --- /dev/null +++ b/apps/start/src/components/report/sidebar/ReportSeries.tsx @@ -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) { + 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 ( +
+
+ + {props.children} +
+ + {/* Segment and Filter buttons - only for events */} + {chartEvent && (showSegment || showAddFilter) && ( +
+ {showSegment && ( + { + dispatch( + changeEvent({ + ...chartEvent, + segment, + }), + ); + }} + /> + )} + {showAddFilter && ( + { + dispatch( + changeEvent({ + ...chartEvent, + filters: [ + ...chartEvent.filters, + { + id: shortId(), + name: action.value, + operator: 'is', + value: [], + }, + ], + }), + ); + }} + > + {(setOpen) => ( + + )} + + )} + + {showSegment && chartEvent.segment.startsWith('property_') && ( + + )} +
+ )} + + {/* Filters - only for events */} + {chartEvent && !isSelectManyEvents && } +
+ ); +} + +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 ( +
+

Metrics

+ + ({ + id: ('type' in e ? e.id : (e as IChartEvent).id) ?? '', + }))} + strategy={verticalListSortingStrategy} + > +
+ {selectedSeries.map((event, index) => { + const isFormula = event.type === 'formula'; + + return ( + + {isFormula ? ( + <> +
+ { + dispatchChangeFormula({ + ...event, + formula: value, + }); + }} + /> + {showDisplayNameInput && ( + { + dispatchChangeFormula({ + ...event, + displayName: e.target.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 && ( + { + dispatchChangeEvent({ + ...(event as IChartEventItem & { + type: 'event'; + }), + displayName: e.target.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 && ( + + )} +
+
+
+
+
+ ); +} diff --git a/apps/start/src/components/report/sidebar/ReportSidebar.tsx b/apps/start/src/components/report/sidebar/ReportSidebar.tsx index f879448b..da28bb75 100644 --- a/apps/start/src/components/report/sidebar/ReportSidebar.tsx +++ b/apps/start/src/components/report/sidebar/ReportSidebar.tsx @@ -3,23 +3,17 @@ import { SheetClose, SheetFooter } from '@/components/ui/sheet'; import { useSelector } from '@/redux'; import { ReportBreakdowns } from './ReportBreakdowns'; -import { ReportEvents } from './ReportEvents'; -import { ReportFormula } from './ReportFormula'; +import { ReportSeries } from './ReportSeries'; import { ReportSettings } from './ReportSettings'; export function ReportSidebar() { const { chartType } = useSelector((state) => state.report); - const showFormula = - chartType !== 'conversion' && - chartType !== 'funnel' && - chartType !== 'retention'; const showBreakdown = chartType !== 'retention'; return ( <>
- + {showBreakdown && } - {showFormula && }
diff --git a/apps/start/src/components/report/sidebar/filters/FilterItem.tsx b/apps/start/src/components/report/sidebar/filters/FilterItem.tsx index f0ec1324..1a2d6be2 100644 --- a/apps/start/src/components/report/sidebar/filters/FilterItem.tsx +++ b/apps/start/src/components/report/sidebar/filters/FilterItem.tsx @@ -39,14 +39,12 @@ interface PureFilterProps { } export function FilterItem({ filter, event }: FilterProps) { - // const { range, startDate, endDate, interval } = useSelector( - // (state) => state.report, - // ); const onRemove = ({ id }: IChartEventFilter) => { dispatch( changeEvent({ ...event, filters: event.filters.filter((item) => item.id !== id), + type: 'event', }), ); }; @@ -58,6 +56,7 @@ export function FilterItem({ filter, event }: FilterProps) { dispatch( changeEvent({ ...event, + type: 'event', filters: event.filters.map((item) => { if (item.id === id) { return { @@ -79,6 +78,7 @@ export function FilterItem({ filter, event }: FilterProps) { dispatch( changeEvent({ ...event, + type: 'event', filters: event.filters.map((item) => { if (item.id === id) { return { diff --git a/apps/start/src/components/ui/input-enter.tsx b/apps/start/src/components/ui/input-enter.tsx index ad2617ee..ec2c6f33 100644 --- a/apps/start/src/components/ui/input-enter.tsx +++ b/apps/start/src/components/ui/input-enter.tsx @@ -4,7 +4,7 @@ import { AnimatePresence } from 'framer-motion'; import { RefreshCcwIcon } from 'lucide-react'; import { type InputHTMLAttributes, useEffect, useState } from 'react'; import { Badge } from './badge'; -import { Input } from './input'; +import { Input, type InputProps } from './input'; export function InputEnter({ value, @@ -13,7 +13,7 @@ export function InputEnter({ }: { value: string | undefined; onChangeValue: (value: string) => void; -} & InputHTMLAttributes) { +} & InputProps) { const [internalValue, setInternalValue] = useState(value ?? ''); useEffect(() => { @@ -33,7 +33,6 @@ export function InputEnter({ onChangeValue(internalValue); } }} - size="default" />
diff --git a/apps/start/src/components/ui/scroll-area.tsx b/apps/start/src/components/ui/scroll-area.tsx index 141e9900..d737b1dc 100644 --- a/apps/start/src/components/ui/scroll-area.tsx +++ b/apps/start/src/components/ui/scroll-area.tsx @@ -1,8 +1,36 @@ import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area'; -import type * as React from 'react'; +import * as React from 'react'; import { cn } from '@/lib/utils'; +export const VirtualScrollArea = React.forwardRef< + HTMLDivElement, + { + children: React.ReactNode; + className?: string; + } +>(({ children, className }, ref) => { + // The ref MUST point directly to the scrollable element + // This element MUST have: + // 1. overflow-y-auto (or overflow: auto) + // 2. A constrained height (via flex-1 min-h-0 or fixed height) + return ( +
+ {children} +
+ ); +}); + +VirtualScrollArea.displayName = 'VirtualScrollArea'; + function ScrollArea({ className, children, diff --git a/apps/start/src/hooks/use-conversion-rechart-data-model.ts b/apps/start/src/hooks/use-conversion-rechart-data-model.ts new file mode 100644 index 00000000..0f09230c --- /dev/null +++ b/apps/start/src/hooks/use-conversion-rechart-data-model.ts @@ -0,0 +1,44 @@ +import type { RouterOutputs } from '@/trpc/client'; +import { useMemo } from 'react'; + +export function useConversionRechartDataModel( + series: RouterOutputs['chart']['conversion']['current'], +) { + return useMemo(() => { + if (!series.length || !series[0]?.data.length) { + return []; + } + + // Get all unique dates from the first series (all series should have same dates) + const dates = series[0].data.map((item) => item.date); + + return dates.map((date) => { + const baseItem = series[0].data.find((item) => item.date === date); + if (!baseItem) { + return { + date, + timestamp: new Date(date).getTime(), + }; + } + + // Build data object with all series values + const dataPoint: Record = { + date, + timestamp: new Date(date).getTime(), + }; + + series.forEach((serie) => { + const item = serie.data.find((d) => d.date === date); + if (item) { + dataPoint[`${serie.id}:rate`] = item.rate; + dataPoint[`${serie.id}:previousRate`] = item.previousRate; + dataPoint[`${serie.id}:total`] = item.total; + dataPoint[`${serie.id}:conversions`] = item.conversions; + } + }); + + return dataPoint; + }); + }, [series]); +} + diff --git a/apps/start/src/hooks/use-visible-conversion-series.ts b/apps/start/src/hooks/use-visible-conversion-series.ts new file mode 100644 index 00000000..f6426604 --- /dev/null +++ b/apps/start/src/hooks/use-visible-conversion-series.ts @@ -0,0 +1,35 @@ +import type { RouterOutputs } from '@/trpc/client'; +import { useEffect, useMemo, useState } from 'react'; + +export type IVisibleConversionSeries = ReturnType< + typeof useVisibleConversionSeries +>['series']; + +export function useVisibleConversionSeries( + data: RouterOutputs['chart']['conversion'], + limit?: number | undefined, +) { + const max = limit ?? 5; + const [visibleSeries, setVisibleSeries] = useState( + data?.current?.slice(0, max).map((serie) => serie.id) ?? [], + ); + + useEffect(() => { + setVisibleSeries( + data?.current?.slice(0, max).map((serie) => serie.id) ?? [], + ); + }, [data, max]); + + return useMemo(() => { + return { + series: data.current + .map((serie, index) => ({ + ...serie, + index, + })) + .filter((serie) => visibleSeries.includes(serie.id)), + setVisibleSeries, + } as const; + }, [visibleSeries, data.current]); +} + diff --git a/apps/start/src/modals/Modal/scrollable-modal.tsx b/apps/start/src/modals/Modal/scrollable-modal.tsx new file mode 100644 index 00000000..47edc57b --- /dev/null +++ b/apps/start/src/modals/Modal/scrollable-modal.tsx @@ -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; +}>({ + 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(null); + return ( + + +
{header}
+ + {children} + + {footer &&
{footer}
} +
+
+ ); +} diff --git a/apps/start/src/modals/event-details.tsx b/apps/start/src/modals/event-details.tsx index 78f64d1f..83962221 100644 --- a/apps/start/src/modals/event-details.tsx +++ b/apps/start/src/modals/event-details.tsx @@ -341,13 +341,14 @@ export default function EventDetails({ id, createdAt, projectId }: Props) { diff --git a/apps/start/src/modals/index.tsx b/apps/start/src/modals/index.tsx index d616746b..e723eaf3 100644 --- a/apps/start/src/modals/index.tsx +++ b/apps/start/src/modals/index.tsx @@ -31,6 +31,7 @@ import RequestPasswordReset from './request-reset-password'; import SaveReport from './save-report'; import SelectBillingPlan from './select-billing-plan'; import ShareOverviewModal from './share-overview-modal'; +import ViewChartUsers from './view-chart-users'; const modals = { OverviewTopPagesModal: OverviewTopPagesModal, @@ -51,6 +52,7 @@ const modals = { EditReference: EditReference, ShareOverviewModal: ShareOverviewModal, AddReference: AddReference, + ViewChartUsers: ViewChartUsers, Instructions: Instructions, OnboardingTroubleshoot: OnboardingTroubleshoot, DateRangerPicker: DateRangerPicker, diff --git a/apps/start/src/modals/view-chart-users.tsx b/apps/start/src/modals/view-chart-users.tsx new file mode 100644 index 00000000..31c7f760 --- /dev/null +++ b/apps/start/src/modals/view-chart-users.tsx @@ -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 ( + { + if (e.metaKey || e.ctrlKey || e.shiftKey) { + return; + } + popModal(); + }} + > +
+ +
+
{getProfileName(profile)}
+
+
+ +
+ {profile.properties.country && ( +
+ + + {profile.properties.country} + {profile.properties.city && ` / ${profile.properties.city}`} + +
+ )} + {profile.properties.os && ( +
+ + {profile.properties.os} +
+ )} + {profile.properties.browser && ( +
+ + {profile.properties.browser} +
+ )} +
+
+ ); +}; +// 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 ( +
+
No users found
+
+ ); + } + + const virtualItems = virtualizer.getVirtualItems(); + + return ( +
+ {/* Only the visible items in the virtualizer, manually positioned to be in view */} + {virtualItems.map((virtualItem) => { + const profile = profiles[virtualItem.index]; + return ( +
+ +
+ ); + })} +
+ ); +} + +// 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( + report.series[0]?.id || null, + ); + const [selectedBreakdownId, setSelectedBreakdownId] = useState( + 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 ( + + + {report.series.length > 0 && ( +
+ + + {matchingChartSeries.length > 1 && ( + + )} +
+ )} +
+ } + > +
+ {profilesQuery.isLoading ? ( +
+
Loading users...
+
+ ) : ( + + )} +
+ + ); +} + +// 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 ( + + + {!isLastStep && ( +
+ + +
+ )} +
+ } + > +
+ {profilesQuery.isLoading ? ( +
+
Loading users...
+
+ ) : ( + + )} +
+ + ); +} + +// 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 ( + + ); + } + + return ( + + ); +} diff --git a/apps/start/src/routes/_app.$organizationId.$projectId.events._tabs.stats.tsx b/apps/start/src/routes/_app.$organizationId.$projectId.events._tabs.stats.tsx index 8b745631..54731f89 100644 --- a/apps/start/src/routes/_app.$organizationId.$projectId.events._tabs.stats.tsx +++ b/apps/start/src/routes/_app.$organizationId.$projectId.events._tabs.stats.tsx @@ -9,7 +9,7 @@ import { useEventQueryNamesFilter, } from '@/hooks/use-event-query-filters'; -import type { IChartEvent } from '@openpanel/validation'; +import type { IChartEventItem } from '@openpanel/validation'; import { createFileRoute } from '@tanstack/react-router'; @@ -23,13 +23,14 @@ function Component() { const { projectId } = Route.useParams(); const [filters] = useEventQueryFilters(); const [events] = useEventQueryNamesFilter(); - const fallback: IChartEvent[] = [ + const fallback: IChartEventItem[] = [ { id: 'A', name: '*', displayName: 'All events', segment: 'event', filters: filters ?? [], + type: 'event', }, ]; @@ -49,7 +50,7 @@ function Component() { projectId={projectId} range="30d" chartType="histogram" - events={ + series={ events && events.length > 0 ? events.map((name) => ({ id: name, @@ -57,6 +58,7 @@ function Component() { displayName: name, segment: 'event', filters: filters ?? [], + type: 'event', })) : fallback } @@ -69,6 +71,11 @@ function Component() { 0 ? events.map((name) => ({ id: name, @@ -86,6 +93,7 @@ function Component() { displayName: name, segment: 'event', filters: filters ?? [], + type: 'event', })) : [ { @@ -94,6 +102,7 @@ function Component() { displayName: 'All events', segment: 'event', filters: filters ?? [], + type: 'event', }, ] } @@ -106,6 +115,11 @@ function Component() { 0 ? events.map((name) => ({ id: name, @@ -123,6 +137,7 @@ function Component() { displayName: name, segment: 'event', filters: filters ?? [], + type: 'event', })) : [ { @@ -131,6 +146,7 @@ function Component() { displayName: 'All events', segment: 'event', filters: filters ?? [], + type: 'event', }, ] } @@ -143,6 +159,11 @@ function Component() { 0 ? events.map((name) => ({ id: name, @@ -160,6 +181,7 @@ function Component() { displayName: name, segment: 'event', filters: filters ?? [], + type: 'event', })) : [ { @@ -168,6 +190,7 @@ function Component() { displayName: 'All events', segment: 'event', filters: filters ?? [], + type: 'event', }, ] } diff --git a/apps/start/src/routes/_app.$organizationId.$projectId.pages.tsx b/apps/start/src/routes/_app.$organizationId.$projectId.pages.tsx index aeb0aeb8..4313d804 100644 --- a/apps/start/src/routes/_app.$organizationId.$projectId.pages.tsx +++ b/apps/start/src/routes/_app.$organizationId.$projectId.pages.tsx @@ -24,20 +24,18 @@ import { createFileRoute } from '@tanstack/react-router'; import { parseAsInteger, useQueryState } from 'nuqs'; import { memo } from 'react'; -export const Route = createFileRoute('/_app/$organizationId/$projectId/pages')( - { - component: Component, - head: () => { - return { - meta: [ - { - title: createProjectTitle(PAGE_TITLES.PAGES), - }, - ], - }; - }, +export const Route = createFileRoute('/_app/$organizationId/$projectId/pages')({ + component: Component, + head: () => { + return { + meta: [ + { + title: createProjectTitle(PAGE_TITLES.PAGES), + }, + ], + }; }, -); +}); function Component() { const { projectId } = Route.useParams(); @@ -220,8 +218,9 @@ const PageCard = memo( chartType: 'linear', projectId, - events: [ + series: [ { + type: 'event', id: 'A', name: 'screen_view', segment: 'event', diff --git a/apps/start/src/styles.css b/apps/start/src/styles.css index 616bf314..401fbb2d 100644 --- a/apps/start/src/styles.css +++ b/apps/start/src/styles.css @@ -339,4 +339,38 @@ button { .animate-ping-slow { 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 */ } \ No newline at end of file diff --git a/apps/start/src/utils/theme.ts b/apps/start/src/utils/theme.ts index c70f4931..111aa87b 100644 --- a/apps/start/src/utils/theme.ts +++ b/apps/start/src/utils/theme.ts @@ -23,9 +23,12 @@ const chartColors = [ ]; 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 { - return chartColors[index % chartColors.length]!.translucent; + return ( + chartColors[index % chartColors.length]?.translucent || + chartColors[0].translucent + ); } diff --git a/packages/db/code-migrations/7-migrate-events-to-series.ts b/packages/db/code-migrations/7-migrate-events-to-series.ts new file mode 100644 index 00000000..37ad34ab --- /dev/null +++ b/packages/db/code-migrations/7-migrate-events-to-series.ts @@ -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 | Partial + >; + 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)`, + ]); +} diff --git a/packages/db/index.ts b/packages/db/index.ts index c0c91330..58042d3f 100644 --- a/packages/db/index.ts +++ b/packages/db/index.ts @@ -3,6 +3,7 @@ export * from './src/clickhouse/client'; export * from './src/clickhouse/csv'; export * from './src/sql-builder'; export * from './src/services/chart.service'; +export * from './src/engine'; export * from './src/services/clients.service'; export * from './src/services/dashboard.service'; export * from './src/services/event.service'; diff --git a/packages/db/package.json b/packages/db/package.json index 284b6d96..fb4cd393 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -25,6 +25,7 @@ "@prisma/extension-read-replicas": "^0.4.1", "fast-deep-equal": "^3.1.3", "jiti": "^2.4.1", + "mathjs": "^12.3.2", "prisma-json-types-generator": "^3.1.1", "ramda": "^0.29.1", "sqlstring": "^2.3.3", diff --git a/packages/db/scripts/ch-copy-from-remote.ts b/packages/db/scripts/ch-copy-from-remote.ts new file mode 100644 index 00000000..18e05445 --- /dev/null +++ b/packages/db/scripts/ch-copy-from-remote.ts @@ -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(); diff --git a/packages/db/scripts/ch-update-sessions-with-revenue.ts b/packages/db/scripts/ch-update-sessions-with-revenue.ts new file mode 100644 index 00000000..93ecb22e --- /dev/null +++ b/packages/db/scripts/ch-update-sessions-with-revenue.ts @@ -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); +}); diff --git a/packages/db/src/clickhouse/query-builder.ts b/packages/db/src/clickhouse/query-builder.ts index 351ef6b1..ba2416c7 100644 --- a/packages/db/src/clickhouse/query-builder.ts +++ b/packages/db/src/clickhouse/query-builder.ts @@ -731,6 +731,7 @@ clix.toInterval = (node: string, interval: IInterval) => { }; clix.toDate = (node: string, interval: IInterval) => { switch (interval) { + case 'day': case 'week': case 'month': { return `toDate(${node})`; diff --git a/packages/db/src/engine/compute.ts b/packages/db/src/engine/compute.ts new file mode 100644 index 00000000..db60c79f --- /dev/null +++ b/packages/db/src/engine/compute.ts @@ -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(); + + // 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(); + breakdownSeries.forEach((serie) => { + seriesByIndex.set(serie.definitionIndex, serie); + }); + + // Get all unique dates across all series in this breakdown group + const allDates = new Set(); + 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 = {}; + 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 = {}; + + // 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; +} diff --git a/packages/db/src/engine/fetch.ts b/packages/db/src/engine/fetch.ts new file mode 100644 index 00000000..954c98e9 --- /dev/null +++ b/packages/db/src/engine/fetch.ts @@ -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 { + 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( + 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( + 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 | 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; +} diff --git a/packages/db/src/engine/format.ts b/packages/db/src/engine/format.ts new file mode 100644 index 00000000..4aaed3bd --- /dev/null +++ b/packages/db/src/engine/format.ts @@ -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, + }; +} diff --git a/packages/db/src/engine/index.ts b/packages/db/src/engine/index.ts new file mode 100644 index 00000000..d9e94d96 --- /dev/null +++ b/packages/db/src/engine/index.ts @@ -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 { + // 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, +}; diff --git a/packages/db/src/engine/normalize.ts b/packages/db/src/engine/normalize.ts new file mode 100644 index 00000000..ed37a3e6 --- /dev/null +++ b/packages/db/src/engine/normalize.ts @@ -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>; + +/** + * Normalize a chart input into a clean structure with dates and normalized series + */ +export async function normalize( + input: IChartInput, +): Promise { + 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; + 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, + }; +} + diff --git a/packages/db/src/engine/plan.ts b/packages/db/src/engine/plan.ts new file mode 100644 index 00000000..1e184c15 --- /dev/null +++ b/packages/db/src/engine/plan.ts @@ -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 { + 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, + }; +} diff --git a/packages/db/src/engine/types.ts b/packages/db/src/engine/types.ts new file mode 100644 index 00000000..7aad9c31 --- /dev/null +++ b/packages/db/src/engine/types.ts @@ -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; // 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; + }; +}; + diff --git a/packages/db/src/services/chart.service.ts b/packages/db/src/services/chart.service.ts index 060501bc..989c3730 100644 --- a/packages/db/src/services/chart.service.ts +++ b/packages/db/src/services/chart.service.ts @@ -155,7 +155,8 @@ export function getChartSql({ } 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.groupBy[key] = `${key}`; }); @@ -175,8 +176,8 @@ export function getChartSql({ if (event.segment === 'property_sum' && event.property) { if (event.property === 'revenue') { - sb.select.count = `sum(revenue) as count`; - sb.where.property = `revenue > 0`; + sb.select.count = 'sum(revenue) as count'; + sb.where.property = 'revenue > 0'; } else { sb.select.count = `sum(toFloat64(${getSelectPropertyKey(event.property)})) as count`; 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.property === 'revenue') { - sb.select.count = `avg(revenue) as count`; - sb.where.property = `revenue > 0`; + sb.select.count = 'avg(revenue) as count'; + sb.where.property = 'revenue > 0'; } else { sb.select.count = `avg(toFloat64(${getSelectPropertyKey(event.property)})) as count`; 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.property === 'revenue') { - sb.select.count = `max(revenue) as count`; - sb.where.property = `revenue > 0`; + sb.select.count = 'max(revenue) as count'; + sb.where.property = 'revenue > 0'; } else { sb.select.count = `max(toFloat64(${getSelectPropertyKey(event.property)})) as count`; 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.property === 'revenue') { - sb.select.count = `min(revenue) as count`; - sb.where.property = `revenue > 0`; + sb.select.count = 'min(revenue) as count'; + sb.where.property = 'revenue > 0'; } else { sb.select.count = `min(toFloat64(${getSelectPropertyKey(event.property)})) as count`; sb.where.property = `${getSelectPropertyKey(event.property)} IS NOT NULL AND notEmpty(${getSelectPropertyKey(event.property)})`; @@ -230,14 +231,58 @@ export function getChartSql({ return sql; } - // Add total unique count for user segment using a scalar subquery - if (event.segment === 'user') { - const totalUniqueSubquery = `( - SELECT ${sb.select.count} + // Build total_count calculation that accounts for breakdowns + // 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 = `( + SELECT ${sb.select.count} + FROM ${sb.from} + ${getJoins()} + ${getWhere()} + )`; sb.select.total_unique_count = `${totalUniqueSubquery} as total_count`; } @@ -509,12 +554,11 @@ export function getChartStartEndDate( }: Pick, timezone: string, ) { - const ranges = getDatesFromRange(range, timezone); - if (startDate && endDate) { return { startDate: startDate, endDate: endDate }; } + const ranges = getDatesFromRange(range, timezone); if (!startDate && endDate) { return { startDate: ranges.startDate, endDate: endDate }; } diff --git a/packages/db/src/services/conversion.service.ts b/packages/db/src/services/conversion.service.ts index 4046ced5..f7e7b86b 100644 --- a/packages/db/src/services/conversion.service.ts +++ b/packages/db/src/services/conversion.service.ts @@ -1,5 +1,5 @@ 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 { TABLE_NAMES, ch } from '../clickhouse/client'; import { clix } from '../clickhouse/query-builder'; @@ -7,6 +7,7 @@ import { getEventFiltersWhereClause, getSelectPropertyKey, } from './chart.service'; +import { onlyReportEvents } from './reports.service'; export class ConversionService { constructor(private client: typeof ch) {} @@ -17,8 +18,9 @@ export class ConversionService { endDate, funnelGroup, funnelWindow = 24, - events, + series, breakdowns = [], + limit, interval, timezone, }: Omit & { @@ -30,6 +32,8 @@ export class ConversionService { ); const breakdownGroupBy = breakdowns.map((b, index) => `b_${index}`); + const events = onlyReportEvents(series); + if (events.length !== 2) { throw new Error('events must be an array of two events'); } @@ -111,18 +115,20 @@ export class ConversionService { } const results = await query.execute(); - return this.toSeries(results, breakdowns).map((serie, serieIndex) => { - return { - ...serie, - data: serie.data.map((d, index) => ({ - ...d, - timestamp: new Date(d.date).getTime(), - serieIndex, - index, - serie: omit(['data'], serie), - })), - }; - }); + return this.toSeries(results, breakdowns, limit).map( + (serie, serieIndex) => { + return { + ...serie, + data: serie.data.map((d, index) => ({ + ...d, + timestamp: new Date(d.date).getTime(), + serieIndex, + index, + serie: omit(['data'], serie), + })), + }; + }, + ); } private toSeries( @@ -134,6 +140,7 @@ export class ConversionService { [key: string]: string | number; }[], breakdowns: { name: string }[] = [], + limit: number | undefined = undefined, ) { if (!breakdowns.length) { return [ @@ -153,6 +160,10 @@ export class ConversionService { // Group by breakdown values const series = data.reduce( (acc, d) => { + if (limit && Object.keys(acc).length >= limit) { + return acc; + } + const key = breakdowns.map((b, index) => d[`b_${index}`]).join('|') || NOT_SET_VALUE; diff --git a/packages/db/src/services/funnel.service.ts b/packages/db/src/services/funnel.service.ts index f2841f38..9ecc204d 100644 --- a/packages/db/src/services/funnel.service.ts +++ b/packages/db/src/services/funnel.service.ts @@ -1,5 +1,9 @@ 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 sqlstring from 'sqlstring'; import { ch } from '../clickhouse/client'; @@ -10,17 +14,18 @@ import { getEventFiltersWhereClause, getSelectPropertyKey, } from './chart.service'; +import { onlyReportEvents } from './reports.service'; export class FunnelService { constructor(private client: typeof ch) {} - private getFunnelGroup(group?: string) { + getFunnelGroup(group?: string): [string, string] { return group === 'profile_id' ? [`COALESCE(nullIf(s.pid, ''), profile_id)`, 'profile_id'] : ['session_id', 'session_id']; } - private getFunnelConditions(events: IChartEvent[]) { + getFunnelConditions(events: IChartEvent[] = []): string[] { return events.map((event) => { const { sb, getWhere } = createSqlBuilder(); 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( funnel: { level: number; count: number }[], steps: number, @@ -57,6 +126,7 @@ export class FunnelService { toSeries( funnel: { level: number; count: number; [key: string]: any }[], breakdowns: { name: string }[] = [], + limit: number | undefined = undefined, ) { if (!breakdowns.length) { return [ @@ -72,6 +142,10 @@ export class FunnelService { // Group by breakdown values const series = funnel.reduce( (acc, f) => { + if (limit && Object.keys(acc).length >= limit) { + return acc; + } + const key = breakdowns.map((b, index) => f[`b_${index}`]).join('|'); if (!acc[key]) { acc[key] = []; @@ -110,51 +184,49 @@ export class FunnelService { projectId, startDate, endDate, - events, + series, funnelWindow = 24, funnelGroup, breakdowns = [], + limit, timezone = 'UTC', - }: IChartInput & { timezone: string }) { + }: IChartInput & { timezone: string; events?: IChartEvent[] }) { if (!startDate || !endDate) { 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'); } const funnelWindowSeconds = funnelWindow * 3600; const funnelWindowMilliseconds = funnelWindowSeconds * 1000; const group = this.getFunnelGroup(funnelGroup); - const funnels = this.getFunnelConditions(events); - const profileFilters = this.getProfileFilters(events); + const profileFilters = this.getProfileFilters(eventSeries); const anyFilterOnProfile = profileFilters.length > 0; const anyBreakdownOnProfile = breakdowns.some((b) => b.name.startsWith('profile.'), ); // Create the funnel CTE - const funnelCte = clix(this.client, timezone) - .select([ - `${group[0]} AS ${group[1]}`, - ...breakdowns.map( - (b, index) => `${getSelectPropertyKey(b.name)} as b_${index}`, - ), - `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', - events.map((e) => e.name), - ) - .groupBy([group[1], ...breakdowns.map((b, index) => `b_${index}`)]); + const breakdownSelects = breakdowns.map( + (b, index) => `${getSelectPropertyKey(b.name)} as b_${index}`, + ); + const breakdownGroupBy = breakdowns.map((b, index) => `b_${index}`); + + const funnelCte = this.buildFunnelCte({ + projectId, + startDate, + endDate, + eventSeries, + funnelWindowMilliseconds, + group, + timezone, + additionalSelects: breakdownSelects, + additionalGroupBy: breakdownGroupBy, + }); if (anyFilterOnProfile || anyBreakdownOnProfile) { funnelCte.leftJoin( @@ -167,15 +239,12 @@ export class FunnelService { // Create the sessions CTE if needed const sessionsCte = group[0] !== 'session_id' - ? clix(this.client, timezone) - // Important to have unique field names to avoid ambiguity in the main query - .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'), - ]) + ? this.buildSessionsCte({ + projectId, + startDate, + endDate, + timezone, + }) : null; // Base funnel query with CTEs @@ -204,11 +273,11 @@ export class FunnelService { .orderBy('level', 'DESC'); const funnelData = await funnelQuery.execute(); - const funnelSeries = this.toSeries(funnelData, breakdowns); + const funnelSeries = this.toSeries(funnelData, breakdowns, limit); return funnelSeries .map((data) => { - const maxLevel = events.length; + const maxLevel = eventSeries.length; const filledFunnelRes = this.fillFunnel( data.map((d) => ({ level: d.level, count: d.count })), maxLevel, @@ -220,7 +289,7 @@ export class FunnelService { (acc, item, index, list) => { const prev = list[index - 1] ?? { count: totalSessions }; const next = list[index + 1]; - const event = events[item.level - 1]!; + const event = eventSeries[item.level - 1]!; return [ ...acc, { diff --git a/packages/db/src/services/reports.service.ts b/packages/db/src/services/reports.service.ts index f704043a..7f8c79c4 100644 --- a/packages/db/src/services/reports.service.ts +++ b/packages/db/src/services/reports.service.ts @@ -5,19 +5,25 @@ import { } from '@openpanel/constants'; import type { IChartBreakdown, - IChartEvent, IChartEventFilter, + IChartEventItem, IChartLineType, IChartProps, IChartRange, ICriteria, } from '@openpanel/validation'; -import { db } from '../prisma-client'; import type { Report as DbReport, ReportLayout } from '../prisma-client'; +import { db } from '../prisma-client'; export type IServiceReport = Awaited>; +export const onlyReportEvents = ( + series: NonNullable['series'], +) => { + return series.filter((item) => item.type === 'event'); +}; + export function transformFilter( filter: Partial, index: number, @@ -31,17 +37,29 @@ export function transformFilter( }; } -export function transformReportEvent( - event: Partial, +export function transformReportEventItem( + item: IChartEventItem, index: number, -): IChartEvent { +): IChartEventItem { + if (item.type === 'formula') { + // Transform formula + return { + type: 'formula', + id: item.id ?? alphabetIds[index]!, + formula: item.formula || '', + displayName: item.displayName, + }; + } + + // Transform event with type field return { - segment: event.segment ?? 'event', - filters: (event.filters ?? []).map(transformFilter), - id: event.id ?? alphabetIds[index]!, - name: event.name || 'unknown_event', - displayName: event.displayName, - property: event.property, + 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 { id: report.id, projectId: report.projectId, - events: (report.events as IChartEvent[]).map(transformReportEvent), + series: + (report.events as IChartEventItem[]).map(transformReportEventItem) ?? [], breakdowns: report.breakdowns as IChartBreakdown[], chartType: report.chartType, lineType: (report.lineType as IChartLineType) ?? lineTypes.monotone, diff --git a/packages/trpc/src/routers/chart.helpers.test.ts b/packages/trpc/src/routers/chart.helpers.test.ts deleted file mode 100644 index 31fcb7ff..00000000 --- a/packages/trpc/src/routers/chart.helpers.test.ts +++ /dev/null @@ -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 => { - 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); - }); - }); -}); diff --git a/packages/trpc/src/routers/chart.helpers.ts b/packages/trpc/src/routers/chart.helpers.ts deleted file mode 100644 index 40cf884f..00000000 --- a/packages/trpc/src/routers/chart.helpers.ts +++ /dev/null @@ -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>, -) { - 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(); - - 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(); - - 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(); - 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 = {}; - - // 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( - getChartSql({ ...payload, timezone }), - { - session_timezone: timezone, - }, - ); - - if (result.length === 0 && payload.breakdowns.length > 0) { - result = await chQuery( - getChartSql({ - ...payload, - breakdowns: [], - timezone, - }), - { - session_timezone: timezone, - }, - ); - } - - return groupByLabels(result).map((serie) => { - return { - ...serie, - event: payload.event, - }; - }); -} - -export type IGetChartSerie = Awaited>[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; -} diff --git a/packages/trpc/src/routers/chart.ts b/packages/trpc/src/routers/chart.ts index 5516b67c..9c23c1b8 100644 --- a/packages/trpc/src/routers/chart.ts +++ b/packages/trpc/src/routers/chart.ts @@ -10,22 +10,32 @@ import { chQuery, clix, conversionService, + createSqlBuilder, db, + formatClickhouseDate, funnelService, getChartPrevStartEndDate, getChartStartEndDate, + getEventFiltersWhereClause, getEventMetasCached, + getProfilesCached, getSelectPropertyKey, getSettingsForProject, + onlyReportEvents, } from '@openpanel/db'; import { + type IChartEvent, + zChartEvent, + zChartEventFilter, zChartInput, + zChartSeries, zCriteria, zRange, zTimeInterval, } from '@openpanel/validation'; import { round } from '@openpanel/common'; +import { ChartEngine } from '@openpanel/db'; import { differenceInDays, differenceInMonths, @@ -40,7 +50,6 @@ import { protectedProcedure, publicProcedure, } from '../trpc'; -import { getChart } from './chart.helpers'; function utc(date: string | Date) { 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 .input( @@ -532,6 +542,200 @@ export const chartRouter = createTRPCRouter({ 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( diff --git a/packages/trpc/src/routers/report.ts b/packages/trpc/src/routers/report.ts index d4ae4cef..b17acb8c 100644 --- a/packages/trpc/src/routers/report.ts +++ b/packages/trpc/src/routers/report.ts @@ -46,7 +46,7 @@ export const reportRouter = createTRPCRouter({ projectId: dashboard.projectId, dashboardId, name: report.name, - events: report.events, + events: report.series, interval: report.interval, breakdowns: report.breakdowns, chartType: report.chartType, @@ -91,7 +91,7 @@ export const reportRouter = createTRPCRouter({ }, data: { name: report.name, - events: report.events, + events: report.series, interval: report.interval, breakdowns: report.breakdowns, chartType: report.chartType, diff --git a/packages/validation/src/index.ts b/packages/validation/src/index.ts index 0ae0b7a0..bc8777ee 100644 --- a/packages/validation/src/index.ts +++ b/packages/validation/src/index.ts @@ -57,12 +57,70 @@ export const zChartEvent = z.object({ .default([]) .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({ id: z.string().optional(), 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 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 zChartInput = z.object({ +export const zChartInputBase = z.object({ chartType: zChartType .default('linear') .describe('What type of chart should be displayed'), @@ -86,8 +144,8 @@ export const zChartInput = z.object({ .describe( 'The time interval for data aggregation (e.g., day, week, month)', ), - events: zChartEvents.describe( - 'Array of events to be tracked and displayed in the chart', + series: zChartSeries.describe( + 'Array of series (events or formulas) to be tracked and displayed in the chart', ), breakdowns: zChartBreakdowns .default([]) @@ -144,7 +202,15 @@ export const zChartInput = z.object({ .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'), lineType: zLineType.describe('The visual style of the line in the chart'), unit: z diff --git a/packages/validation/src/test.ts b/packages/validation/src/test.ts new file mode 100644 index 00000000..34d3fbc6 --- /dev/null +++ b/packages/validation/src/test.ts @@ -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); diff --git a/packages/validation/src/types.validation.ts b/packages/validation/src/types.validation.ts index 2c348dca..38a13418 100644 --- a/packages/validation/src/types.validation.ts +++ b/packages/validation/src/types.validation.ts @@ -1,11 +1,18 @@ import type { z } from 'zod'; +export type UnionOmit = T extends any + ? Omit + : never; + import type { zChartBreakdown, zChartEvent, + zChartEventItem, zChartEventSegment, + zChartFormula, zChartInput, zChartInputAI, + zChartSeries, zChartType, zCriteria, zLineType, @@ -24,6 +31,11 @@ export type IChartProps = z.infer & { previousIndicatorInverted?: boolean; }; export type IChartEvent = z.infer; +export type IChartFormula = z.infer; +export type IChartEventItem = z.infer; +export type IChartSeries = z.infer; +// Backward compatibility alias +export type IChartEvents = IChartSeries; export type IChartEventSegment = z.infer; export type IChartEventFilter = IChartEvent['filters'][number]; export type IChartEventFilterValue = @@ -45,7 +57,7 @@ export type IGetChartDataInput = { projectId: string; startDate: string; endDate: string; -} & Omit; +} & Omit; export type ICriteria = z.infer; export type PreviousValue = @@ -77,6 +89,7 @@ export type IChartSerie = { event: { id?: string; name: string; + breakdowns?: Record; }; metrics: Metrics; data: { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 30d76eee..24ab211f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -629,9 +629,6 @@ importers: lucide-react: specifier: ^0.476.0 version: 0.476.0(react@19.1.1) - mathjs: - specifier: ^12.3.2 - version: 12.3.2 mitt: specifier: ^3.0.1 version: 3.0.1 @@ -1071,6 +1068,9 @@ importers: jiti: specifier: ^2.4.1 version: 2.4.1 + mathjs: + specifier: ^12.3.2 + version: 12.3.2 prisma-json-types-generator: specifier: ^3.1.1 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: resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} - decimal.js@10.4.3: - resolution: {integrity: sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==} - decimal.js@10.6.0: resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} @@ -26295,8 +26292,6 @@ snapshots: decimal.js-light@2.5.1: {} - decimal.js@10.4.3: {} - decimal.js@10.6.0: {} decode-named-character-reference@1.0.2: @@ -29314,7 +29309,7 @@ snapshots: dependencies: '@babel/runtime': 7.23.9 complex.js: 2.1.1 - decimal.js: 10.4.3 + decimal.js: 10.6.0 escape-latex: 1.2.0 fraction.js: 4.3.4 javascript-natural-sort: 0.7.1