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..850fd768 100644
--- a/apps/api/src/controllers/export.controller.ts
+++ b/apps/api/src/controllers/export.controller.ts
@@ -12,8 +12,12 @@ 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,
+ zChartInput,
+ zChartInputBase,
+} from '@openpanel/validation';
import { omit } from 'ramda';
async function getProjectId(
@@ -139,7 +143,7 @@ export async function events(
});
}
-const chartSchemeFull = zChartInput
+const chartSchemeFull = zChartInputBase
.pick({
breakdowns: true,
interval: true,
@@ -151,14 +155,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 +196,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 +219,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/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..a7bf83f0
--- /dev/null
+++ b/apps/start/src/components/report-chart/common/report-table-toolbar.tsx
@@ -0,0 +1,46 @@
+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(e.target.value)}
+ className="pl-8"
+ />
+
+
+
+
+
+
+ );
+}
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..4a398080
--- /dev/null
+++ b/apps/start/src/components/report-chart/common/report-table-utils.ts
@@ -0,0 +1,325 @@
+import { getPropertyLabel } from '@/translations/properties';
+import type { IChartData } from '@/trpc/client';
+
+export type TableRow = {
+ id: string;
+ serieName: string;
+ breakdownValues: string[];
+ sum: number;
+ average: number;
+ min: number;
+ max: number;
+ dateValues: Record
; // date -> count
+ originalSerie: IChartData['series'][0];
+ // Group metadata for collapse functionality
+ groupKey?: string; // Unique key for the group this row belongs to
+ parentGroupKey?: string; // Key of parent group (for nested groups)
+ isSummaryRow?: boolean; // True if this is a summary row for a collapsed group
+};
+
+export type GroupedTableRow = TableRow & {
+ // For grouped mode, indicates which breakdown levels should show empty cells
+ breakdownDisplay: (string | null)[]; // null means show empty cell
+};
+
+/**
+ * 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,
+ serieName: serie.names[0] ?? '',
+ breakdownValues: serie.names.slice(1),
+ sum: serie.metrics.sum,
+ average: serie.metrics.average,
+ min: serie.metrics.min,
+ max: serie.metrics.max,
+ dateValues,
+ originalSerie: serie,
+ };
+ });
+}
+
+/**
+ * Transform series into grouped table rows
+ * Groups rows hierarchically by breakdown values
+ */
+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)[] = [];
+
+ if (index === 0) {
+ // First row shows all breakdown values
+ breakdownDisplay.push(...row.breakdownValues);
+ } else {
+ // Subsequent rows: compare with first row in group
+ const firstRow = groupRows[0]!;
+
+ for (let i = 0; i < row.breakdownValues.length; i++) {
+ // Show empty if this breakdown matches the first row at this position
+ if (i < firstRow.breakdownValues.length) {
+ if (row.breakdownValues[i] === firstRow.breakdownValues[i]) {
+ breakdownDisplay.push(null);
+ } else {
+ breakdownDisplay.push(row.breakdownValues[i]!);
+ }
+ } else {
+ breakdownDisplay.push(row.breakdownValues[i]!);
+ }
+ }
+ }
+
+ 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 {
+ // Aggregate metrics from all rows in the group
+ const totalSum = groupRows.reduce((sum, row) => sum + row.sum, 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
+ const dateValues: Record = {};
+ const allDates = new Set();
+ groupRows.forEach((row) => {
+ Object.keys(row.dateValues).forEach((date) => {
+ allDates.add(date);
+ dateValues[date] = (dateValues[date] ?? 0) + row.dateValues[date];
+ });
+ });
+
+ // Get breakdown values from first row
+ const firstRow = groupRows[0]!;
+ const breakdownDisplay: (string | null)[] = [];
+ breakdownDisplay.push(firstRow.breakdownValues[0] ?? null);
+ // Fill remaining breakdowns with null (empty)
+ for (let i = 1; i < breakdownCount; i++) {
+ breakdownDisplay.push(null);
+ }
+
+ return {
+ id: `summary-${groupKey}`,
+ serieName: firstRow.serieName,
+ breakdownValues: firstRow.breakdownValues,
+ sum: totalSum,
+ average: totalAverage,
+ min: totalMin,
+ max: totalMax,
+ dateValues,
+ originalSerie: firstRow.originalSerie,
+ 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,
+ };
+}
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..59adc4c0 100644
--- a/apps/start/src/components/report-chart/common/report-table.tsx
+++ b/apps/start/src/components/report-chart/common/report-table.tsx
@@ -1,6 +1,3 @@
-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,
@@ -8,35 +5,73 @@ import {
TableCell,
TableHead,
TableHeader,
- TableRow,
+ TableRow as UITableRow,
} 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 } from '@tanstack/react-table';
+import {
+ type SortingState,
+ flexRender,
+ getCoreRowModel,
+ getFilteredRowModel,
+ getSortedRowModel,
+ useReactTable,
+} from '@tanstack/react-table';
+import {
+ type VirtualItem,
+ useWindowVirtualizer,
+} from '@tanstack/react-virtual';
+import throttle from 'lodash.throttle';
+import { ChevronDown, ChevronRight } from 'lucide-react';
+import { useEffect, useMemo, useRef, useState } from 'react';
import type * as React from 'react';
-import { logDependencies } from 'mathjs';
-import { PreviousDiffIndicator } from './previous-diff-indicator';
+import { ReportTableToolbar } from './report-table-toolbar';
+import {
+ type GroupedTableRow,
+ type TableRow,
+ createSummaryRow,
+ 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
export function ReportTable({
data,
visibleSeries,
setVisibleSeries,
}: ReportTableProps) {
- const { setPage, paginate, page } = usePagination(ROWS_LIMIT);
+ const [grouped, setGrouped] = useState(true);
+ const [collapsedGroups, setCollapsedGroups] = useState>(
+ new Set(),
+ );
+ const [sorting, setSorting] = useState([]);
+ const [globalFilter, setGlobalFilter] = useState('');
+ const [columnSizing, setColumnSizing] = useState>({});
+ 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);
@@ -45,149 +80,870 @@ export function ReportTable({
short: true,
});
- function handleChange(name: string, checked: boolean) {
- setVisibleSeries((prev) => {
- if (checked) {
- return [...prev, name];
+ // Transform data to table format
+ const {
+ rows: rawRows,
+ dates,
+ breakdownPropertyNames,
+ } = useMemo(
+ () => transformToTableData(data, breakdowns, grouped),
+ [data, breakdowns, grouped],
+ );
+
+ // Filter rows based on collapsed groups and create summary rows
+ const rows = useMemo(() => {
+ if (!grouped || collapsedGroups.size === 0) {
+ return rawRows;
+ }
+
+ const processedRows: (TableRow | GroupedTableRow)[] = [];
+ const groupedRows = rawRows as GroupedTableRow[];
+
+ // Group rows by their groupKey
+ const rowsByGroup = new Map();
+ groupedRows.forEach((row) => {
+ if (row.groupKey) {
+ if (!rowsByGroup.has(row.groupKey)) {
+ rowsByGroup.set(row.groupKey, []);
+ }
+ rowsByGroup.get(row.groupKey)!.push(row);
+ } else {
+ // Rows without groupKey go directly to processed
+ processedRows.push(row);
}
- return prev.filter((item) => item !== name);
});
+
+ // Process each group
+ rowsByGroup.forEach((groupRows, groupKey) => {
+ if (collapsedGroups.has(groupKey)) {
+ // Group is collapsed - show summary row
+ const summaryRow = createSummaryRow(
+ groupRows,
+ groupKey,
+ breakdownPropertyNames.length,
+ );
+ processedRows.push(summaryRow);
+ } else {
+ // Group is expanded - show all rows
+ processedRows.push(...groupRows);
+ }
+ });
+
+ return processedRows;
+ }, [rawRows, collapsedGroups, grouped, breakdownPropertyNames.length]);
+
+ // 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 = ['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, sort within each group
+ if (grouped && sorting.length > 0 && result.length > 0) {
+ const groupedRows = result as GroupedTableRow[];
+
+ // Group rows by their groupKey
+ const rowsByGroup = new Map();
+ const ungroupedRows: GroupedTableRow[] = [];
+
+ groupedRows.forEach((row) => {
+ if (row.groupKey) {
+ if (!rowsByGroup.has(row.groupKey)) {
+ rowsByGroup.set(row.groupKey, []);
+ }
+ rowsByGroup.get(row.groupKey)!.push(row);
+ } else {
+ ungroupedRows.push(row);
+ }
+ });
+
+ // Sort function based on current sort state
+ const sortFn = (a: GroupedTableRow, b: GroupedTableRow) => {
+ 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];
+ bValue = b[metric];
+ } else if (id.startsWith('date-')) {
+ const date = id.replace('date-', '');
+ aValue = a.dateValues[date] ?? 0;
+ bValue = b.dateValues[date] ?? 0;
+ } else {
+ continue;
+ }
+
+ // Compare values
+ if (aValue < bValue) return desc ? 1 : -1;
+ if (aValue > bValue) return desc ? -1 : 1;
+ }
+ return 0;
+ };
+
+ // Sort groups themselves by their first row's sort value
+ const groupsArray = Array.from(rowsByGroup.entries());
+ groupsArray.sort((a, b) => {
+ const aFirst = a[1][0];
+ const bFirst = b[1][0];
+ if (!aFirst || !bFirst) return 0;
+ return sortFn(aFirst, bFirst);
+ });
+
+ // Rebuild result with sorted groups
+ const finalResult: GroupedTableRow[] = [];
+ groupsArray.forEach(([, groupRows]) => {
+ const sorted = [...groupRows].sort(sortFn);
+ finalResult.push(...sorted);
+ });
+ finalResult.push(...ungroupedRows.sort(sortFn));
+
+ return finalResult;
+ }
+
+ return result;
+ }, [rows, globalFilter, grouped, sorting]);
+
+ // Calculate min/max values for color visualization
+ const { metricRanges, dateRanges } = useMemo(() => {
+ const metricRanges: Record = {
+ 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,
+ };
+ });
+
+ rows.forEach((row) => {
+ // Calculate metric ranges
+ Object.keys(metricRanges).forEach((key) => {
+ const value = row[key as keyof typeof row] as number;
+ if (typeof value === 'number') {
+ 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,
+ };
+ }
+ 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 and opacity for a value
+ const getCellBackground = (
+ value: number,
+ min: number,
+ max: number,
+ ): { opacity: number; className: string } => {
+ if (value === 0 || max === min) {
+ return { opacity: 0, className: '' };
+ }
+
+ const percentage = (value - min) / (max - min);
+ const opacity = Math.max(0.05, Math.min(1, percentage));
+
+ return {
+ opacity,
+ className: 'bg-highlight dark:bg-emerald-700',
+ };
+ };
+
+ // 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]);
+
+ // 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
+ const toggleGroupCollapse = (groupKey: string) => {
+ setCollapsedGroups((prev) => {
+ const next = new Set(prev);
+ if (next.has(groupKey)) {
+ next.delete(groupKey);
+ } else {
+ next.add(groupKey);
+ }
+ return next;
+ });
+ };
+
+ // 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 serieName = row.original.serieName;
+ const serieId = row.original.originalSerie.id;
+ const isVisible = visibleSeriesIds.includes(serieId);
+ const serieIndex = getSerieIndex(serieId);
+ const color = getChartColor(serieIndex);
+
+ return (
+
+ toggleSerieVisibility(serieId)}
+ style={{
+ borderColor: color,
+ backgroundColor: isVisible ? color : 'transparent',
+ }}
+ className="h-4 w-4 shrink-0"
+ />
+
+
+ );
+ },
+ });
+
+ // 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 unique group keys for this breakdown level
+ const groupKeys = new Set();
+ (rawRows as GroupedTableRow[]).forEach((row) => {
+ if (row.groupKey) {
+ groupKeys.add(row.groupKey);
+ }
+ });
+
+ // Check if all groups at this level are collapsed
+ const allCollapsed = Array.from(groupKeys).every((key) =>
+ collapsedGroups.has(key),
+ );
+
+ return (
+ {
+ // Toggle all groups at this breakdown level
+ groupKeys.forEach((key) => toggleGroupCollapse(key));
+ }}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ groupKeys.forEach((key) => toggleGroupCollapse(key));
+ }
+ }}
+ role="button"
+ tabIndex={0}
+ >
+ {allCollapsed ? (
+
+ ) : (
+
+ )}
+ {propertyName}
+
+ );
+ },
+ meta: {
+ pinned: 'left',
+ isBreakdown: true,
+ breakdownIndex: index,
+ },
+ cell: ({ row }) => {
+ const original = row.original;
+ let value: string | null;
+
+ if ('breakdownDisplay' in original && grouped) {
+ value = original.breakdownDisplay[index] ?? null;
+ } else {
+ value = original.breakdownValues[index] ?? null;
+ }
+
+ const isSummary = original.isSummaryRow ?? false;
+
+ return (
+
+ {value || ''}
+
+ );
+ },
+ });
+ });
+
+ // Metric columns
+ const metrics = [
+ { 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 isSummary = row.original.isSummaryRow ?? false;
+ const range = metricRanges[metric.key];
+ const { opacity, className } = range
+ ? getCellBackground(value, range.min, range.max)
+ : { opacity: 0, className: '' };
+
+ return (
+
+
+
0.7 &&
+ 'text-white [text-shadow:_0_0_3px_rgb(0_0_0_/_20%)]',
+ )}
+ >
+ {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 range = dateRanges[date];
+ const { opacity, className } = range
+ ? getCellBackground(value, range.min, range.max)
+ : { opacity: 0, className: '' };
+
+ return (
+
+
+
0.7 &&
+ 'text-white [text-shadow:_0_0_3px_rgb(0_0_0_/_20%)]',
+ )}
+ >
+ {number.format(value)}
+
+
+ );
+ },
+ });
+ });
+
+ return cols;
+ }, [
+ breakdownPropertyNames,
+ dates,
+ formatDate,
+ number,
+ grouped,
+ visibleSeriesIds,
+ collapsedGroups,
+ rawRows,
+ metricRanges,
+ dateRanges,
+ columnSizing,
+ ]);
+
+ const table = useReactTable({
+ data: filteredRows,
+ columns,
+ getCoreRowModel: getCoreRowModel(),
+ getSortedRowModel: grouped ? getCoreRowModel() : getSortedRowModel(), // Disable TanStack sorting when grouped
+ getFilteredRowModel: getFilteredRowModel(),
+ filterFns: {
+ isWithinRange: () => true,
+ },
+ enableColumnResizing: true,
+ columnResizeMode: 'onChange',
+ state: {
+ sorting,
+ columnSizing,
+ },
+ onSortingChange: setSorting,
+ onColumnSizingChange: setColumnSizing,
+ globalFilterFn: () => true, // We handle filtering manually
+ manualSorting: grouped, // Manual sorting when grouped
+ });
+
+ // 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;
+ }, 100);
+ }
+ };
+
+ window.addEventListener('mouseup', handleMouseUp);
+ window.addEventListener('touchend', handleMouseUp);
+
+ return () => {
+ window.removeEventListener('mouseup', handleMouseUp);
+ window.removeEventListener('touchend', handleMouseUp);
+ };
+ }, []);
+
+ const virtualizer = useWindowVirtualizer({
+ count: filteredRows.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);
+
+ // Get pinned columns
+ const leftPinnedColumns = table
+ .getAllColumns()
+ .filter((col) => col.columnDef.meta?.pinned === 'left')
+ .filter((col): col is NonNullable => col !== undefined);
+ const rightPinnedColumns = table
+ .getAllColumns()
+ .filter((col) => col.columnDef.meta?.pinned === 'right')
+ .filter((col): col is NonNullable => col !== undefined);
+
+ // Helper to get pinning styles
+ const getPinningStyles = (
+ column: ReturnType | undefined,
+ ) => {
+ if (!column) return {};
+ const isPinned = column.columnDef.meta?.pinned;
+ if (!isPinned) 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();
+ }
+ }
+
+ return {
+ 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,
+ };
+ };
+
+ 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 */}
+
`${h.getSize()}px`)
+ .join(' ') ?? '',
+ minWidth: 'fit-content',
+ }}
+ >
+ {table.getHeaderGroups()[0]?.headers.map((header) => {
+ const column = header.column;
+ 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;
+ header.getResizeHandler()(e);
+ }}
+ onMouseUp={() => {
+ // Use setTimeout to allow the resize to complete before resetting
+ setTimeout(() => {
+ isResizingRef.current = false;
+ }, 0);
+ }}
+ onTouchStart={(e) => {
+ e.stopPropagation();
+ isResizingRef.current = true;
+ header.getResizeHandler()(e);
+ }}
+ onTouchEnd={() => {
+ setTimeout(() => {
+ isResizingRef.current = false;
+ }, 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)}
-
-
-
- );
- })}
-
- );
- })}
-
-
+ {/* Virtualized Body */}
+
+ {virtualRows.map((virtualRow) => {
+ const tableRow = table.getRowModel().rows[virtualRow.index];
+ if (!tableRow) return null;
+
+ return (
+
`${h.getSize()}px`)
+ .join(' ') ?? '',
+ minWidth: 'fit-content',
+ }}
+ className="border-b hover:bg-muted/30 transition-colors"
+ >
+ {table.getHeaderGroups()[0]?.headers.map((header) => {
+ const column = header.column;
+ const cell = tableRow
+ .getVisibleCells()
+ .find((c) => c.column.id === column.id);
+ if (!cell) return null;
+
+ const isBreakdown =
+ column.columnDef.meta?.isBreakdown ?? false;
+ const pinningStyles = getPinningStyles(column);
+ const isMetricOrDate =
+ column.id.startsWith('metric-') ||
+ column.id.startsWith('date-');
+
+ const canResize = column.getCanResize();
+ const isPinned = column.columnDef.meta?.pinned === 'left';
+
+ return (
+
+ {flexRender(
+ cell.column.columnDef.cell,
+ cell.getContext(),
+ )}
+ {canResize && isPinned && (
+
{
+ e.stopPropagation();
+ isResizingRef.current = true;
+ header.getResizeHandler()(e);
+ }}
+ onMouseUp={() => {
+ setTimeout(() => {
+ isResizingRef.current = false;
+ }, 0);
+ }}
+ onTouchStart={(e) => {
+ e.stopPropagation();
+ isResizingRef.current = true;
+ header.getResizeHandler()(e);
+ }}
+ onTouchEnd={() => {
+ setTimeout(() => {
+ isResizingRef.current = false;
+ }, 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',
+ column.getIsResizing() && 'bg-primary',
+ )}
+ />
+ )}
+
+ );
+ })}
+
+ );
+ })}
+
-
- {/*
*/}
- >
+
);
}
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/index.tsx b/apps/start/src/components/report-chart/funnel/index.tsx
index e80063cc..fdffd671 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',
@@ -44,7 +44,7 @@ export function ReportFunnelChart() {
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/line/chart.tsx b/apps/start/src/components/report-chart/line/chart.tsx
index ab3365e3..87b6e500 100644
--- a/apps/start/src/components/report-chart/line/chart.tsx
+++ b/apps/start/src/components/report-chart/line/chart.tsx
@@ -1,3 +1,9 @@
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu';
import { useRechartDataModel } from '@/hooks/use-rechart-data-model';
import { useVisibleSeries } from '@/hooks/use-visible-series';
import { useTRPC } from '@/integrations/trpc/react';
@@ -5,17 +11,12 @@ import { pushModal } from '@/modals';
import type { IChartData } from '@/trpc/client';
import { cn } from '@/utils/cn';
import { getChartColor } from '@/utils/theme';
+import type { IChartEvent } from '@openpanel/validation';
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, useState } from 'react';
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuTrigger,
-} from '@/components/ui/dropdown-menu';
-import { UsersIcon, BookmarkIcon } from 'lucide-react';
import {
CartesianGrid,
ComposedChart,
@@ -51,14 +52,20 @@ export function Chart({ data }: Props) {
endDate,
range,
lineType,
- events,
+ series: reportSeries,
breakdowns,
},
isEditMode,
options: { hideXAxis, hideYAxis, maxDomain },
} = useReportChartContext();
- const [clickPosition, setClickPosition] = useState<{ x: number; y: number } | null>(null);
- const [clickedData, setClickedData] = useState<{ date: string; serieId?: string } | null>(null);
+ const [clickPosition, setClickPosition] = useState<{
+ x: number;
+ y: number;
+ } | null>(null);
+ const [clickedData, setClickedData] = useState<{
+ date: string;
+ serieId?: string;
+ } | null>(null);
const dataLength = data.series[0]?.data?.length || 0;
const trpc = useTRPC();
const references = useQuery(
@@ -144,9 +151,19 @@ export function Chart({ data }: Props) {
const payload = e.activePayload[0].payload;
const activeCoordinate = e.activeCoordinate;
if (payload.date) {
+ // Find the first valid serie ID from activePayload (skip calcStrokeDasharray)
+ 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', '');
+
setClickedData({
date: payload.date,
- serieId: e.activePayload[0].dataKey?.toString().replace(':count', ''),
+ serieId,
});
setClickPosition({
x: activeCoordinate?.x ?? 0,
@@ -157,36 +174,39 @@ export function Chart({ data }: Props) {
}, []);
const handleViewUsers = useCallback(() => {
- if (!clickedData || !projectId || !startDate || !endDate) return;
-
- // Find the event for the clicked serie
- const serie = series.find((s) => s.id === clickedData.serieId);
- const event = events.find((e) => {
- const normalized = 'type' in e ? e : { ...e, type: 'event' as const };
- if (normalized.type === 'event') {
- return serie?.event.id === normalized.id || serie?.event.name === normalized.name;
- }
- return false;
- });
+ if (!clickedData || !projectId) return;
- if (event) {
- const normalized = 'type' in event ? event : { ...event, type: 'event' as const };
- if (normalized.type === 'event') {
- pushModal('ViewChartUsers', {
- projectId,
- event: normalized,
- date: clickedData.date,
- breakdowns: breakdowns || [],
- interval,
- startDate,
- endDate,
- filters: normalized.filters || [],
- });
- }
- }
+ // Pass the chart data (which we already have) and the report config
+ pushModal('ViewChartUsers', {
+ chartData: data,
+ report: {
+ projectId,
+ series: reportSeries,
+ breakdowns: breakdowns || [],
+ interval,
+ startDate,
+ endDate,
+ range,
+ previous,
+ chartType: 'linear',
+ metric: 'sum',
+ },
+ date: clickedData.date,
+ });
setClickPosition(null);
setClickedData(null);
- }, [clickedData, projectId, startDate, endDate, events, series, breakdowns, interval]);
+ }, [
+ clickedData,
+ projectId,
+ data,
+ reportSeries,
+ breakdowns,
+ interval,
+ startDate,
+ endDate,
+ range,
+ previous,
+ ]);
const handleAddReference = useCallback(() => {
if (!clickedData) return;
diff --git a/apps/start/src/components/report-chart/retention/index.tsx b/apps/start/src/components/report-chart/retention/index.tsx
index ff2d777d..58bbffff 100644
--- a/apps/start/src/components/report-chart/retention/index.tsx
+++ b/apps/start/src/components/report-chart/retention/index.tsx
@@ -12,7 +12,7 @@ import CohortTable from './table';
export function ReportRetentionChart() {
const {
report: {
- events,
+ series,
range,
projectId,
startDate,
@@ -22,8 +22,9 @@ export function ReportRetentionChart() {
},
isLazyLoading,
} = useReportChartContext();
- const firstEvent = (events[0]?.filters[0]?.value ?? []).map(String);
- const secondEvent = (events[1]?.filters[0]?.value ?? []).map(String);
+ const eventSeries = series.filter((item) => item.type === 'event');
+ const firstEvent = (eventSeries[0]?.filters?.[0]?.value ?? []).map(String);
+ const secondEvent = (eventSeries[1]?.filters?.[0]?.value ?? []).map(String);
const isEnabled =
firstEvent.length > 0 && secondEvent.length > 0 && !isLazyLoading;
const trpc = useTRPC();
diff --git a/apps/start/src/components/report/reportSlice.ts b/apps/start/src/components/report/reportSlice.ts
index 93d7436e..b216e549 100644
--- a/apps/start/src/components/report/reportSlice.ts
+++ b/apps/start/src/components/report/reportSlice.ts
@@ -41,7 +41,7 @@ const initialState: InitialState = {
lineType: 'monotone',
interval: 'day',
breakdowns: [],
- events: [],
+ series: [],
range: '30d',
startDate: null,
endDate: null,
@@ -88,10 +88,10 @@ export const reportSlice = createSlice({
state.dirty = true;
state.name = action.payload;
},
- // Events and Formulas
+ // Series (Events and Formulas)
addEvent: (state, action: PayloadAction>) => {
state.dirty = true;
- state.events.push({
+ state.series.push({
id: shortId(),
type: 'event',
...action.payload,
@@ -102,7 +102,7 @@ export const reportSlice = createSlice({
action: PayloadAction>,
) => {
state.dirty = true;
- state.events.push({
+ state.series.push({
id: shortId(),
...action.payload,
} as IChartEventItem);
@@ -113,16 +113,16 @@ export const reportSlice = createSlice({
) => {
state.dirty = true;
if (action.payload.type === 'event') {
- state.events.push({
- ...action.payload,
- filters: action.payload.filters.map((filter) => ({
- ...filter,
- id: shortId(),
- })),
+ state.series.push({
+ ...action.payload,
+ filters: action.payload.filters.map((filter) => ({
+ ...filter,
id: shortId(),
+ })),
+ id: shortId(),
} as IChartEventItem);
} else {
- state.events.push({
+ state.series.push({
...action.payload,
id: shortId(),
} as IChartEventItem);
@@ -135,7 +135,7 @@ export const reportSlice = createSlice({
}>,
) => {
state.dirty = true;
- state.events = state.events.filter(
+ state.series = state.series.filter(
(event) => {
// Handle both old format (no type) and new format
const eventId = 'type' in event ? event.id : (event as IChartEvent).id;
@@ -145,7 +145,7 @@ export const reportSlice = createSlice({
},
changeEvent: (state, action: PayloadAction) => {
state.dirty = true;
- state.events = state.events.map((event) => {
+ state.series = state.series.map((event) => {
const eventId = 'type' in event ? event.id : (event as IChartEvent).id;
if (eventId === action.payload.id) {
return action.payload;
@@ -293,9 +293,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);
}
},
},
diff --git a/apps/start/src/components/report/sidebar/ReportEvents.tsx b/apps/start/src/components/report/sidebar/ReportEvents.tsx
index 50c9d1d3..20004f7e 100644
--- a/apps/start/src/components/report/sidebar/ReportEvents.tsx
+++ b/apps/start/src/components/report/sidebar/ReportEvents.tsx
@@ -266,60 +266,60 @@ export function ReportEvents() {
>
) : (
<>
- {
- dispatch(
- changeEvent(
- Array.isArray(value)
- ? {
+ }
+ onChange={(value) => {
+ dispatch(
+ changeEvent(
+ Array.isArray(value)
+ ? {
id: normalized.id,
type: 'event',
- segment: 'user',
- filters: [
- {
- name: 'name',
- operator: 'is',
- value: value,
- },
- ],
- name: '*',
- }
- : {
+ segment: 'user',
+ filters: [
+ {
+ name: 'name',
+ operator: 'is',
+ value: value,
+ },
+ ],
+ name: '*',
+ }
+ : {
...normalized,
type: 'event',
- name: value,
- filters: [],
- },
- ),
- );
- }}
- items={eventNames}
- placeholder="Select event"
- />
- {showDisplayNameInput && (
-
+ {showDisplayNameInput && (
+ {
- dispatchChangeEvent({
+ onChange={(e) => {
+ dispatchChangeEvent({
...(normalized as IChartEventItem & { type: 'event' }),
- displayName: e.target.value,
- });
- }}
- />
- )}
+ displayName: e.target.value,
+ });
+ }}
+ />
+ )}
>
)}
@@ -328,38 +328,38 @@ export function ReportEvents() {
})}
-
{
- 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(
+ 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}
+ />
{showFormula && (