This commit is contained in:
Carl-Gerhard Lindesvärd
2025-11-21 11:21:17 +01:00
parent dd71fd4e11
commit 06fb6c4f3c
40 changed files with 2944 additions and 1972 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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 (
<div className="col md:row md:items-center gap-2 p-2 border-b md:justify-between">
<div className="relative flex-1 w-full md:max-w-sm">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
placeholder="Search..."
value={search}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-8"
/>
</div>
<div className="flex items-center gap-2">
<Button
variant={'outline'}
size="sm"
onClick={onToggleGrouped}
icon={grouped ? Rows3 : List}
>
{grouped ? 'Grouped' : 'Flat'}
</Button>
<Button variant="outline" size="sm" onClick={onUnselectAll} icon={X}>
Unselect All
</Button>
</div>
</div>
);
}

View File

@@ -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<string, number>; // 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<string>();
series.forEach((serie) => {
serie.data.forEach((d) => {
dateSet.add(d.date);
});
});
return Array.from(dateSet).sort();
}
/**
* Get breakdown property names from series
* Breakdown values are in names.slice(1), so we need to infer the property names
* from the breakdowns array or from the series structure
*/
function getBreakdownPropertyNames(
series: IChartData['series'],
breakdowns: Array<{ name: string }>,
): string[] {
// If we have breakdowns from state, use those
if (breakdowns.length > 0) {
return breakdowns.map((b) => getPropertyLabel(b.name));
}
// Otherwise, infer from series names
// All series should have the same number of breakdown values
if (series.length === 0) return [];
const firstSerie = series[0];
const breakdownCount = firstSerie.names.length - 1;
return Array.from({ length: breakdownCount }, (_, i) => `Breakdown ${i + 1}`);
}
/**
* Transform series into flat table rows
*/
export function createFlatRows(
series: IChartData['series'],
dates: string[],
): TableRow[] {
return series.map((serie) => {
const dateValues: Record<string, number> = {};
dates.forEach((date) => {
const dataPoint = serie.data.find((d) => d.date === date);
dateValues[date] = dataPoint?.count ?? 0;
});
return {
id: serie.id,
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<string, TableRow[]>();
flatRows.forEach((row) => {
const firstBreakdown = row.breakdownValues[0] ?? '';
if (!groupsByFirstBreakdown.has(firstBreakdown)) {
groupsByFirstBreakdown.set(firstBreakdown, []);
}
groupsByFirstBreakdown.get(firstBreakdown)!.push(row);
});
// Sort groups by sum of highest row in group
const sortedGroups = Array.from(groupsByFirstBreakdown.entries()).sort(
(a, b) => {
const aMax = Math.max(...a[1].map((r) => r.sum));
const bMax = Math.max(...b[1].map((r) => r.sum));
return bMax - aMax;
},
);
// Process each group hierarchically
sortedGroups.forEach(([firstBreakdownValue, groupRows]) => {
// Within each first-breakdown group, sort by sum
groupRows.sort((a, b) => b.sum - a.sum);
// Generate group key for this first-breakdown group
const groupKey = firstBreakdownValue;
// For each row in the group
groupRows.forEach((row, index) => {
const breakdownDisplay: (string | null)[] = [];
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<string, number> = {};
const allDates = new Set<string>();
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<string>();
series.forEach((serie) => {
const value = serie.names[index + 1]; // +1 because names[0] is serie name
if (value) {
uniqueValues.add(value);
}
});
return { index, count: uniqueValues.size };
});
// Sort by count (ascending - fewest first)
uniqueCounts.sort((a, b) => a.count - b.count);
// Create reordered names and mapping
const reorderedNames = uniqueCounts.map(
(item) => breakdownPropertyNames[item.index]!,
);
const reorderMap = uniqueCounts.map((item) => item.index); // new index -> old index
const reverseMap = new Array(breakdownPropertyNames.length);
reorderMap.forEach((oldIndex, newIndex) => {
reverseMap[oldIndex] = newIndex;
});
return { reorderedNames, reorderMap, reverseMap };
}
/**
* Transform chart data into table-ready format
*/
export function transformToTableData(
data: IChartData,
breakdowns: Array<{ name: string }>,
grouped: boolean,
): {
rows: TableRow[] | GroupedTableRow[];
dates: string[];
breakdownPropertyNames: string[];
} {
const dates = getUniqueDates(data.series);
const originalBreakdownPropertyNames = getBreakdownPropertyNames(
data.series,
breakdowns,
);
// Reorder breakdowns by unique count (fewest first)
const { reorderedNames: breakdownPropertyNames, reorderMap } =
reorderBreakdownsByUniqueCount(data.series, originalBreakdownPropertyNames);
// Reorder breakdown values in series before creating rows
const reorderedSeries = data.series.map((serie) => {
const reorderedNames = [
serie.names[0], // Keep serie name first
...reorderMap.map((oldIndex) => serie.names[oldIndex + 1] ?? ''), // Reorder breakdown values
];
return {
...serie,
names: reorderedNames,
};
});
const rows = grouped
? createGroupedRows(reorderedSeries, dates)
: createFlatRows(reorderedSeries, dates);
// Sort flat rows by sum descending
if (!grouped) {
(rows as TableRow[]).sort((a, b) => b.sum - a.sum);
}
return {
rows,
dates,
breakdownPropertyNames,
};
}

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -1,3 +1,9 @@
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useRechartDataModel } from '@/hooks/use-rechart-data-model'; import { useRechartDataModel } from '@/hooks/use-rechart-data-model';
import { useVisibleSeries } from '@/hooks/use-visible-series'; import { useVisibleSeries } from '@/hooks/use-visible-series';
import { useTRPC } from '@/integrations/trpc/react'; import { useTRPC } from '@/integrations/trpc/react';
@@ -5,17 +11,12 @@ import { pushModal } from '@/modals';
import type { IChartData } from '@/trpc/client'; import type { IChartData } from '@/trpc/client';
import { cn } from '@/utils/cn'; import { cn } from '@/utils/cn';
import { getChartColor } from '@/utils/theme'; import { getChartColor } from '@/utils/theme';
import type { IChartEvent } from '@openpanel/validation';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { isSameDay, isSameHour, isSameMonth, isSameWeek } from 'date-fns'; import { isSameDay, isSameHour, isSameMonth, isSameWeek } from 'date-fns';
import { BookmarkIcon, UsersIcon } from 'lucide-react';
import { last } from 'ramda'; import { last } from 'ramda';
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { UsersIcon, BookmarkIcon } from 'lucide-react';
import { import {
CartesianGrid, CartesianGrid,
ComposedChart, ComposedChart,
@@ -51,14 +52,20 @@ export function Chart({ data }: Props) {
endDate, endDate,
range, range,
lineType, lineType,
events, series: reportSeries,
breakdowns, breakdowns,
}, },
isEditMode, isEditMode,
options: { hideXAxis, hideYAxis, maxDomain }, options: { hideXAxis, hideYAxis, maxDomain },
} = useReportChartContext(); } = useReportChartContext();
const [clickPosition, setClickPosition] = useState<{ x: number; y: number } | null>(null); const [clickPosition, setClickPosition] = useState<{
const [clickedData, setClickedData] = useState<{ date: string; serieId?: string } | null>(null); 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 dataLength = data.series[0]?.data?.length || 0;
const trpc = useTRPC(); const trpc = useTRPC();
const references = useQuery( const references = useQuery(
@@ -144,9 +151,19 @@ export function Chart({ data }: Props) {
const payload = e.activePayload[0].payload; const payload = e.activePayload[0].payload;
const activeCoordinate = e.activeCoordinate; const activeCoordinate = e.activeCoordinate;
if (payload.date) { 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({ setClickedData({
date: payload.date, date: payload.date,
serieId: e.activePayload[0].dataKey?.toString().replace(':count', ''), serieId,
}); });
setClickPosition({ setClickPosition({
x: activeCoordinate?.x ?? 0, x: activeCoordinate?.x ?? 0,
@@ -157,36 +174,39 @@ export function Chart({ data }: Props) {
}, []); }, []);
const handleViewUsers = useCallback(() => { const handleViewUsers = useCallback(() => {
if (!clickedData || !projectId || !startDate || !endDate) return; if (!clickedData || !projectId) 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 (event) { // Pass the chart data (which we already have) and the report config
const normalized = 'type' in event ? event : { ...event, type: 'event' as const }; pushModal('ViewChartUsers', {
if (normalized.type === 'event') { chartData: data,
pushModal('ViewChartUsers', { report: {
projectId, projectId,
event: normalized, series: reportSeries,
date: clickedData.date, breakdowns: breakdowns || [],
breakdowns: breakdowns || [], interval,
interval, startDate,
startDate, endDate,
endDate, range,
filters: normalized.filters || [], previous,
}); chartType: 'linear',
} metric: 'sum',
} },
date: clickedData.date,
});
setClickPosition(null); setClickPosition(null);
setClickedData(null); setClickedData(null);
}, [clickedData, projectId, startDate, endDate, events, series, breakdowns, interval]); }, [
clickedData,
projectId,
data,
reportSeries,
breakdowns,
interval,
startDate,
endDate,
range,
previous,
]);
const handleAddReference = useCallback(() => { const handleAddReference = useCallback(() => {
if (!clickedData) return; if (!clickedData) return;

View File

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

View File

@@ -41,7 +41,7 @@ const initialState: InitialState = {
lineType: 'monotone', lineType: 'monotone',
interval: 'day', interval: 'day',
breakdowns: [], breakdowns: [],
events: [], series: [],
range: '30d', range: '30d',
startDate: null, startDate: null,
endDate: null, endDate: null,
@@ -88,10 +88,10 @@ export const reportSlice = createSlice({
state.dirty = true; state.dirty = true;
state.name = action.payload; state.name = action.payload;
}, },
// Events and Formulas // Series (Events and Formulas)
addEvent: (state, action: PayloadAction<Omit<IChartEvent, 'id'>>) => { addEvent: (state, action: PayloadAction<Omit<IChartEvent, 'id'>>) => {
state.dirty = true; state.dirty = true;
state.events.push({ state.series.push({
id: shortId(), id: shortId(),
type: 'event', type: 'event',
...action.payload, ...action.payload,
@@ -102,7 +102,7 @@ export const reportSlice = createSlice({
action: PayloadAction<Omit<IChartFormula, 'id'>>, action: PayloadAction<Omit<IChartFormula, 'id'>>,
) => { ) => {
state.dirty = true; state.dirty = true;
state.events.push({ state.series.push({
id: shortId(), id: shortId(),
...action.payload, ...action.payload,
} as IChartEventItem); } as IChartEventItem);
@@ -113,16 +113,16 @@ export const reportSlice = createSlice({
) => { ) => {
state.dirty = true; state.dirty = true;
if (action.payload.type === 'event') { if (action.payload.type === 'event') {
state.events.push({ state.series.push({
...action.payload, ...action.payload,
filters: action.payload.filters.map((filter) => ({ filters: action.payload.filters.map((filter) => ({
...filter, ...filter,
id: shortId(),
})),
id: shortId(), id: shortId(),
})),
id: shortId(),
} as IChartEventItem); } as IChartEventItem);
} else { } else {
state.events.push({ state.series.push({
...action.payload, ...action.payload,
id: shortId(), id: shortId(),
} as IChartEventItem); } as IChartEventItem);
@@ -135,7 +135,7 @@ export const reportSlice = createSlice({
}>, }>,
) => { ) => {
state.dirty = true; state.dirty = true;
state.events = state.events.filter( state.series = state.series.filter(
(event) => { (event) => {
// Handle both old format (no type) and new format // Handle both old format (no type) and new format
const eventId = 'type' in event ? event.id : (event as IChartEvent).id; const eventId = 'type' in event ? event.id : (event as IChartEvent).id;
@@ -145,7 +145,7 @@ export const reportSlice = createSlice({
}, },
changeEvent: (state, action: PayloadAction<IChartEventItem>) => { changeEvent: (state, action: PayloadAction<IChartEventItem>) => {
state.dirty = true; 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; const eventId = 'type' in event ? event.id : (event as IChartEvent).id;
if (eventId === action.payload.id) { if (eventId === action.payload.id) {
return action.payload; return action.payload;
@@ -293,9 +293,9 @@ export const reportSlice = createSlice({
) { ) {
state.dirty = true; state.dirty = true;
const { fromIndex, toIndex } = action.payload; const { fromIndex, toIndex } = action.payload;
const [movedEvent] = state.events.splice(fromIndex, 1); const [movedEvent] = state.series.splice(fromIndex, 1);
if (movedEvent) { if (movedEvent) {
state.events.splice(toIndex, 0, movedEvent); state.series.splice(toIndex, 0, movedEvent);
} }
}, },
}, },

View File

@@ -266,60 +266,60 @@ export function ReportEvents() {
</> </>
) : ( ) : (
<> <>
<ComboboxEvents <ComboboxEvents
className="flex-1" className="flex-1"
searchable searchable
multiple={isSelectManyEvents as false} multiple={isSelectManyEvents as false}
value={ value={
(isSelectManyEvents (isSelectManyEvents
? ((normalized as IChartEventItem & { type: 'event' }).filters[0]?.value ?? []) ? ((normalized as IChartEventItem & { type: 'event' }).filters[0]?.value ?? [])
: (normalized as IChartEventItem & { type: 'event' }).name) as any : (normalized as IChartEventItem & { type: 'event' }).name) as any
} }
onChange={(value) => { onChange={(value) => {
dispatch( dispatch(
changeEvent( changeEvent(
Array.isArray(value) Array.isArray(value)
? { ? {
id: normalized.id, id: normalized.id,
type: 'event', type: 'event',
segment: 'user', segment: 'user',
filters: [ filters: [
{ {
name: 'name', name: 'name',
operator: 'is', operator: 'is',
value: value, value: value,
}, },
], ],
name: '*', name: '*',
} }
: { : {
...normalized, ...normalized,
type: 'event', type: 'event',
name: value, name: value,
filters: [], filters: [],
}, },
), ),
); );
}} }}
items={eventNames} items={eventNames}
placeholder="Select event" placeholder="Select event"
/> />
{showDisplayNameInput && ( {showDisplayNameInput && (
<Input <Input
placeholder={ placeholder={
(normalized as IChartEventItem & { type: 'event' }).name (normalized as IChartEventItem & { type: 'event' }).name
? `${(normalized as IChartEventItem & { type: 'event' }).name} (${alphabetIds[index]})` ? `${(normalized as IChartEventItem & { type: 'event' }).name} (${alphabetIds[index]})`
: 'Display name' : 'Display name'
} }
defaultValue={(normalized as IChartEventItem & { type: 'event' }).displayName} defaultValue={(normalized as IChartEventItem & { type: 'event' }).displayName}
onChange={(e) => { onChange={(e) => {
dispatchChangeEvent({ dispatchChangeEvent({
...(normalized as IChartEventItem & { type: 'event' }), ...(normalized as IChartEventItem & { type: 'event' }),
displayName: e.target.value, displayName: e.target.value,
}); });
}} }}
/> />
)} )}
<ReportEventMore onClick={handleMore(normalized)} /> <ReportEventMore onClick={handleMore(normalized)} />
</> </>
)} )}
@@ -328,38 +328,38 @@ export function ReportEvents() {
})} })}
<div className="flex gap-2"> <div className="flex gap-2">
<ComboboxEvents <ComboboxEvents
disabled={isAddEventDisabled} disabled={isAddEventDisabled}
value={''} value={''}
searchable searchable
onChange={(value) => { onChange={(value) => {
if (isSelectManyEvents) { if (isSelectManyEvents) {
dispatch( dispatch(
addEvent({ addEvent({
segment: 'user', segment: 'user',
name: value, name: value,
filters: [ filters: [
{ {
name: 'name', name: 'name',
operator: 'is', operator: 'is',
value: [value], value: [value],
}, },
], ],
}), }),
); );
} else { } else {
dispatch( dispatch(
addEvent({ addEvent({
name: value, name: value,
segment: 'event', segment: 'event',
filters: [], filters: [],
}), }),
); );
} }
}} }}
placeholder="Select event" placeholder="Select event"
items={eventNames} items={eventNames}
/> />
{showFormula && ( {showFormula && (
<Button <Button
type="button" type="button"

View File

@@ -1,24 +0,0 @@
import { useDispatch, useSelector } from '@/redux';
import { InputEnter } from '@/components/ui/input-enter';
import { changeFormula } from '../reportSlice';
export function ReportFormula() {
const formula = useSelector((state) => state.report.formula);
const dispatch = useDispatch();
return (
<div>
<h3 className="mb-2 font-medium">Formula</h3>
<div className="flex flex-col gap-4">
<InputEnter
placeholder="eg: A/B"
value={formula ?? ''}
onChangeValue={(value) => {
dispatch(changeFormula(value));
}}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,413 @@
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, PlusIcon } from 'lucide-react';
import { ReportSegment } from '../ReportSegment';
import {
addEvent,
addFormula,
changeEvent,
duplicateEvent,
removeEvent,
reorderEvents,
} from '../reportSlice';
import { EventPropertiesCombobox } from './EventPropertiesCombobox';
import { PropertiesCombobox } from './PropertiesCombobox';
import type { ReportEventMoreProps } from './ReportEventMore';
import { ReportEventMore } from './ReportEventMore';
import { FiltersList } from './filters/FiltersList';
function SortableSeries({
event,
index,
showSegment,
showAddFilter,
isSelectManyEvents,
...props
}: {
event: IChartEventItem | IChartEvent;
index: number;
showSegment: boolean;
showAddFilter: boolean;
isSelectManyEvents: boolean;
} & React.HTMLAttributes<HTMLDivElement>) {
const dispatch = useDispatch();
const eventId = 'type' in event ? event.id : (event as IChartEvent).id;
const { attributes, listeners, setNodeRef, transform, transition } =
useSortable({ id: eventId ?? '' });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
// Normalize event to have type field
const normalizedEvent: IChartEventItem =
'type' in event ? event : { ...event, type: 'event' as const };
const isFormula = normalizedEvent.type === 'formula';
const chartEvent = isFormula
? null
: (normalizedEvent as IChartEventItem & { type: 'event' });
return (
<div ref={setNodeRef} style={style} {...attributes} {...props}>
<div className="flex items-center gap-2 p-2 group">
<button className="cursor-grab active:cursor-grabbing" {...listeners}>
<ColorSquare className="relative">
<HandIcon className="size-3 opacity-0 scale-50 group-hover:opacity-100 group-hover:scale-100 transition-all absolute inset-1" />
<span className="block group-hover:opacity-0 group-hover:scale-0 transition-all">
{alphabetIds[index]}
</span>
</ColorSquare>
</button>
{props.children}
</div>
{/* Segment and Filter buttons - only for events */}
{chartEvent && (showSegment || showAddFilter) && (
<div className="flex gap-2 p-2 pt-0">
{showSegment && (
<ReportSegment
value={chartEvent.segment}
onChange={(segment) => {
dispatch(
changeEvent({
...chartEvent,
segment,
}),
);
}}
/>
)}
{showAddFilter && (
<PropertiesCombobox
event={chartEvent}
onSelect={(action) => {
dispatch(
changeEvent({
...chartEvent,
filters: [
...chartEvent.filters,
{
id: shortId(),
name: action.value,
operator: 'is',
value: [],
},
],
}),
);
}}
>
{(setOpen) => (
<button
onClick={() => setOpen((p) => !p)}
type="button"
className="flex items-center gap-1 rounded-md border border-border bg-card p-1 px-2 text-sm font-medium leading-none"
>
<FilterIcon size={12} /> Add filter
</button>
)}
</PropertiesCombobox>
)}
{showSegment && chartEvent.segment.startsWith('property_') && (
<EventPropertiesCombobox event={chartEvent} />
)}
</div>
)}
{/* Filters - only for events */}
{chartEvent && !isSelectManyEvents && <FiltersList event={chartEvent} />}
</div>
);
}
export function ReportSeries() {
const selectedSeries = useSelector((state) => state.report.series);
const chartType = useSelector((state) => state.report.chartType);
const dispatch = useDispatch();
const { projectId } = useAppParams();
const eventNames = useEventNames({
projectId,
});
const showSegment = !['retention', 'funnel'].includes(chartType);
const showAddFilter = !['retention'].includes(chartType);
const showDisplayNameInput = !['retention'].includes(chartType);
const isAddEventDisabled =
(chartType === 'retention' || chartType === 'conversion') &&
selectedSeries.length >= 2;
const dispatchChangeEvent = useDebounceFn((event: IChartEvent) => {
dispatch(changeEvent(event));
});
const isSelectManyEvents = chartType === 'retention';
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
const oldIndex = selectedSeries.findIndex((e) => e.id === active.id);
const newIndex = selectedSeries.findIndex((e) => e.id === over.id);
dispatch(reorderEvents({ fromIndex: oldIndex, toIndex: newIndex }));
}
};
const handleMore = (event: IChartEventItem | IChartEvent) => {
const callback: ReportEventMoreProps['onClick'] = (action) => {
switch (action) {
case 'remove': {
return dispatch(
removeEvent({
id: 'type' in event ? event.id : (event as IChartEvent).id,
}),
);
}
case 'duplicate': {
const normalized =
'type' in event ? event : { ...event, type: 'event' as const };
return dispatch(duplicateEvent(normalized));
}
}
};
return callback;
};
const dispatchChangeFormula = useDebounceFn((formula: IChartFormula) => {
dispatch(changeEvent(formula));
});
const showFormula =
chartType !== 'conversion' &&
chartType !== 'funnel' &&
chartType !== 'retention';
return (
<div>
<h3 className="mb-2 font-medium">Metrics</h3>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={selectedSeries.map((e) => ({
id: ('type' in e ? e.id : (e as IChartEvent).id) ?? '',
}))}
strategy={verticalListSortingStrategy}
>
<div className="flex flex-col gap-4">
{selectedSeries.map((event, index) => {
const isFormula = event.type === 'formula';
return (
<SortableSeries
key={event.id}
event={event}
index={index}
showSegment={showSegment}
showAddFilter={showAddFilter}
isSelectManyEvents={isSelectManyEvents}
className="rounded-lg border bg-def-100"
>
{isFormula ? (
<>
<div className="flex-1 flex flex-col gap-2">
<InputEnter
placeholder="eg: A+B"
value={event.formula}
onChangeValue={(value) => {
dispatchChangeFormula({
...event,
formula: value,
});
}}
/>
{showDisplayNameInput && (
<Input
placeholder={`Name: Formula (${alphabetIds[index]})`}
defaultValue={event.displayName}
onChange={(e) => {
dispatchChangeFormula({
...event,
displayName: e.target.value,
});
}}
/>
)}
</div>
<ReportEventMore onClick={handleMore(event)} />
</>
) : (
<>
<ComboboxEvents
className="flex-1"
searchable
multiple={isSelectManyEvents as false}
value={
(isSelectManyEvents
? ((
event as IChartEventItem & {
type: 'event';
}
).filters[0]?.value ?? [])
: (
event as IChartEventItem & {
type: 'event';
}
).name) as any
}
onChange={(value) => {
dispatch(
changeEvent(
Array.isArray(value)
? {
id: event.id,
type: 'event',
segment: 'user',
filters: [
{
name: 'name',
operator: 'is',
value: value,
},
],
name: '*',
}
: {
...event,
type: 'event',
name: value,
filters: [],
},
),
);
}}
items={eventNames}
placeholder="Select event"
/>
{showDisplayNameInput && (
<Input
placeholder={
(event as IChartEventItem & { type: 'event' }).name
? `${(event as IChartEventItem & { type: 'event' }).name} (${alphabetIds[index]})`
: 'Display name'
}
defaultValue={
(event as IChartEventItem & { type: 'event' })
.displayName
}
onChange={(e) => {
dispatchChangeEvent({
...(event as IChartEventItem & {
type: 'event';
}),
displayName: e.target.value,
});
}}
/>
)}
<ReportEventMore onClick={handleMore(event)} />
</>
)}
</SortableSeries>
);
})}
<div className="flex gap-2">
<ComboboxEvents
disabled={isAddEventDisabled}
value={''}
searchable
onChange={(value) => {
if (isSelectManyEvents) {
dispatch(
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 && (
<Button
type="button"
variant="outline"
icon={PiIcon}
onClick={() => {
dispatch(
addFormula({
type: 'formula',
formula: '',
displayName: '',
}),
);
}}
>
Add Formula
</Button>
)}
</div>
</div>
</SortableContext>
</DndContext>
</div>
);
}

View File

@@ -3,7 +3,7 @@ import { SheetClose, SheetFooter } from '@/components/ui/sheet';
import { useSelector } from '@/redux'; import { useSelector } from '@/redux';
import { ReportBreakdowns } from './ReportBreakdowns'; import { ReportBreakdowns } from './ReportBreakdowns';
import { ReportEvents } from './ReportEvents'; import { ReportSeries } from './ReportSeries';
import { ReportFormula } from './ReportFormula'; import { ReportFormula } from './ReportFormula';
import { ReportSettings } from './ReportSettings'; import { ReportSettings } from './ReportSettings';
@@ -13,7 +13,7 @@ export function ReportSidebar() {
return ( return (
<> <>
<div className="flex flex-col gap-8"> <div className="flex flex-col gap-8">
<ReportEvents /> <ReportSeries />
{showBreakdown && <ReportBreakdowns />} {showBreakdown && <ReportBreakdowns />}
<ReportSettings /> <ReportSettings />
</div> </div>

View File

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

View File

@@ -1,62 +1,197 @@
import { ButtonContainer } from '@/components/button-container'; import { ButtonContainer } from '@/components/button-container';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useTRPC } from '@/integrations/trpc/react'; import { useTRPC } from '@/integrations/trpc/react';
import type { IChartData } from '@/trpc/client';
import type { IChartEvent, IChartInput } from '@openpanel/validation';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { UsersIcon } from 'lucide-react'; import { UsersIcon } from 'lucide-react';
import { useMemo, useState } from 'react';
import { popModal } from '.'; import { popModal } from '.';
import { ModalContent, ModalHeader } from './Modal/Container'; import { ModalContent, ModalHeader } from './Modal/Container';
import type { IChartEvent } from '@openpanel/validation';
interface ViewChartUsersProps { interface ViewChartUsersProps {
projectId: string; chartData: IChartData;
event: IChartEvent; report: IChartInput;
date: string; date: string;
breakdowns?: Array<{ id?: string; name: string }>;
interval: string;
startDate: string;
endDate: string;
filters?: Array<{
id?: string;
name: string;
operator: string;
value: Array<string | number | boolean | null>;
}>;
} }
export default function ViewChartUsers({ export default function ViewChartUsers({
projectId, chartData,
event, report,
date, date,
breakdowns = [],
interval,
startDate,
endDate,
filters = [],
}: ViewChartUsersProps) { }: ViewChartUsersProps) {
const trpc = useTRPC(); const trpc = useTRPC();
const query = useQuery(
trpc.chart.getProfiles.queryOptions({ // Group series by base event/formula (ignoring breakdowns)
projectId, const baseSeries = useMemo(() => {
event, const grouped = new Map<
date, string,
breakdowns, {
interval: interval as any, baseName: string;
startDate, baseEventId: string;
endDate, reportSerie: IChartInput['series'][0] | undefined;
filters, breakdownSeries: Array<{
}), serie: IChartData['series'][0];
breakdowns: Record<string, string> | undefined;
}>;
}
>();
chartData.series.forEach((serie) => {
const baseEventId = serie.event.id || '';
const baseName = serie.names[0] || 'Unnamed Serie';
if (!grouped.has(baseEventId)) {
const reportSerie = report.series.find((ss) => ss.id === baseEventId);
grouped.set(baseEventId, {
baseName,
baseEventId,
reportSerie,
breakdownSeries: [],
});
}
const group = grouped.get(baseEventId);
if (!group) return;
// Extract breakdowns from serie.event.breakdowns (set in format.ts)
const breakdowns = (serie.event as any).breakdowns;
group.breakdownSeries.push({
serie,
breakdowns,
});
});
return Array.from(grouped.values());
}, [chartData.series, report.series, report.breakdowns]);
const [selectedBaseSerieId, setSelectedBaseSerieId] = useState<string | null>(
null,
);
const [selectedBreakdownIndex, setSelectedBreakdownIndex] = useState<
number | null
>(null);
const selectedBaseSerie = useMemo(
() => baseSeries.find((bs) => bs.baseEventId === selectedBaseSerieId),
[baseSeries, selectedBaseSerieId],
); );
const profiles = query.data ?? []; const selectedBreakdown = useMemo(() => {
if (
!selectedBaseSerie ||
selectedBreakdownIndex === null ||
!selectedBaseSerie.breakdownSeries[selectedBreakdownIndex]
) {
return null;
}
return selectedBaseSerie.breakdownSeries[selectedBreakdownIndex];
}, [selectedBaseSerie, selectedBreakdownIndex]);
// Reset breakdown selection when base serie changes
const handleBaseSerieChange = (value: string) => {
setSelectedBaseSerieId(value);
setSelectedBreakdownIndex(null);
};
const selectedSerie = selectedBreakdown || selectedBaseSerie;
const profilesQuery = useQuery(
trpc.chart.getProfiles.queryOptions(
{
projectId: report.projectId,
date: date,
series:
selectedSerie &&
selectedBaseSerie?.reportSerie &&
selectedBaseSerie.reportSerie.type === 'event'
? [selectedBaseSerie.reportSerie]
: [],
breakdowns: selectedBreakdown?.breakdowns,
interval: report.interval,
},
{
enabled:
!!selectedSerie &&
!!selectedBaseSerie?.reportSerie &&
selectedBaseSerie.reportSerie.type === 'event',
},
),
);
const profiles = profilesQuery.data ?? [];
return ( return (
<ModalContent> <ModalContent>
<ModalHeader <ModalHeader title="View Users" />
title="View Users" <p className="text-sm text-muted-foreground mb-4">
description={`Users who triggered this event on ${new Date(date).toLocaleDateString()}`} Users who performed actions on {new Date(date).toLocaleDateString()}
/> </p>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{query.isLoading ? ( {baseSeries.length > 0 && (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<label className="text-sm font-medium">Serie:</label>
<Select
value={selectedBaseSerieId || ''}
onValueChange={handleBaseSerieChange}
>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="Select Serie" />
</SelectTrigger>
<SelectContent>
{baseSeries.map((baseSerie) => (
<SelectItem
key={baseSerie.baseEventId}
value={baseSerie.baseEventId}
>
{baseSerie.baseName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{selectedBaseSerie &&
selectedBaseSerie.breakdownSeries.length > 1 && (
<div className="flex items-center gap-2">
<label className="text-sm font-medium">Breakdown:</label>
<Select
value={selectedBreakdownIndex?.toString() || ''}
onValueChange={(value) =>
setSelectedBreakdownIndex(
value ? Number.parseInt(value, 10) : null,
)
}
>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="All Breakdowns" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">All Breakdowns</SelectItem>
{selectedBaseSerie.breakdownSeries.map((bdSerie, idx) => (
<SelectItem
key={bdSerie.serie.id}
value={idx.toString()}
>
{bdSerie.serie.names.slice(1).join(' > ') ||
'No Breakdown'}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
)}
{profilesQuery.isLoading ? (
<div className="flex items-center justify-center py-8"> <div className="flex items-center justify-center py-8">
<div className="text-muted-foreground">Loading users...</div> <div className="text-muted-foreground">Loading users...</div>
</div> </div>
@@ -109,4 +244,3 @@ export default function ViewChartUsers({
</ModalContent> </ModalContent>
); );
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,141 @@
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,
): 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: 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,
})),
};
});
// 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,
metrics: globalMetrics,
};
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -155,7 +155,8 @@ export function getChartSql({
} }
breakdowns.forEach((breakdown, index) => { breakdowns.forEach((breakdown, index) => {
const key = `label_${index}`; // Breakdowns start at label_1 (label_0 is reserved for event name)
const key = `label_${index + 1}`;
sb.select[key] = `${getSelectPropertyKey(breakdown.name)} as ${key}`; sb.select[key] = `${getSelectPropertyKey(breakdown.name)} as ${key}`;
sb.groupBy[key] = `${key}`; sb.groupBy[key] = `${key}`;
}); });

View File

@@ -1,5 +1,5 @@
import { NOT_SET_VALUE } from '@openpanel/constants'; import { NOT_SET_VALUE } from '@openpanel/constants';
import type { IChartInput } from '@openpanel/validation'; import type { IChartEvent, IChartInput } from '@openpanel/validation';
import { omit } from 'ramda'; import { omit } from 'ramda';
import { TABLE_NAMES, ch } from '../clickhouse/client'; import { TABLE_NAMES, ch } from '../clickhouse/client';
import { clix } from '../clickhouse/query-builder'; import { clix } from '../clickhouse/query-builder';
@@ -17,7 +17,8 @@ export class ConversionService {
endDate, endDate,
funnelGroup, funnelGroup,
funnelWindow = 24, funnelWindow = 24,
events, series,
events, // Backward compatibility - use series if available
breakdowns = [], breakdowns = [],
interval, interval,
timezone, timezone,
@@ -30,7 +31,12 @@ export class ConversionService {
); );
const breakdownGroupBy = breakdowns.map((b, index) => `b_${index}`); const breakdownGroupBy = breakdowns.map((b, index) => `b_${index}`);
if (events.length !== 2) { // Use series if available, otherwise fall back to events (backward compat)
const eventSeries = (series ?? events ?? []).filter(
(item): item is IChartEvent => item.type === 'event',
) as IChartEvent[];
if (eventSeries.length !== 2) {
throw new Error('events must be an array of two events'); throw new Error('events must be an array of two events');
} }
@@ -38,8 +44,8 @@ export class ConversionService {
throw new Error('startDate and endDate are required'); throw new Error('startDate and endDate are required');
} }
const eventA = events[0]!; const eventA = eventSeries[0]!;
const eventB = events[1]!; const eventB = eventSeries[1]!;
const whereA = Object.values( const whereA = Object.values(
getEventFiltersWhereClause(eventA.filters), getEventFiltersWhereClause(eventA.filters),
).join(' AND '); ).join(' AND ');

View File

@@ -20,7 +20,7 @@ export class FunnelService {
: ['session_id', 'session_id']; : ['session_id', 'session_id'];
} }
private getFunnelConditions(events: IChartEvent[]) { private getFunnelConditions(events: IChartEvent[] = []) {
return events.map((event) => { return events.map((event) => {
const { sb, getWhere } = createSqlBuilder(); const { sb, getWhere } = createSqlBuilder();
sb.where = getEventFiltersWhereClause(event.filters); sb.where = getEventFiltersWhereClause(event.filters);
@@ -110,7 +110,8 @@ export class FunnelService {
projectId, projectId,
startDate, startDate,
endDate, endDate,
events, series,
events, // Backward compatibility - use series if available
funnelWindow = 24, funnelWindow = 24,
funnelGroup, funnelGroup,
breakdowns = [], breakdowns = [],
@@ -120,15 +121,20 @@ export class FunnelService {
throw new Error('startDate and endDate are required'); throw new Error('startDate and endDate are required');
} }
if (events.length === 0) { // Use series if available, otherwise fall back to events (backward compat)
const eventSeries = (series ?? events ?? []).filter(
(item): item is IChartEvent => item.type === 'event',
) as IChartEvent[];
if (eventSeries.length === 0) {
throw new Error('events are required'); throw new Error('events are required');
} }
const funnelWindowSeconds = funnelWindow * 3600; const funnelWindowSeconds = funnelWindow * 3600;
const funnelWindowMilliseconds = funnelWindowSeconds * 1000; const funnelWindowMilliseconds = funnelWindowSeconds * 1000;
const group = this.getFunnelGroup(funnelGroup); const group = this.getFunnelGroup(funnelGroup);
const funnels = this.getFunnelConditions(events); const funnels = this.getFunnelConditions(eventSeries);
const profileFilters = this.getProfileFilters(events); const profileFilters = this.getProfileFilters(eventSeries);
const anyFilterOnProfile = profileFilters.length > 0; const anyFilterOnProfile = profileFilters.length > 0;
const anyBreakdownOnProfile = breakdowns.some((b) => const anyBreakdownOnProfile = breakdowns.some((b) =>
b.name.startsWith('profile.'), b.name.startsWith('profile.'),
@@ -152,7 +158,7 @@ export class FunnelService {
.where( .where(
'name', 'name',
'IN', 'IN',
events.map((e) => e.name), eventSeries.map((e) => e.name),
) )
.groupBy([group[1], ...breakdowns.map((b, index) => `b_${index}`)]); .groupBy([group[1], ...breakdowns.map((b, index) => `b_${index}`)]);
@@ -208,7 +214,7 @@ export class FunnelService {
return funnelSeries return funnelSeries
.map((data) => { .map((data) => {
const maxLevel = events.length; const maxLevel = eventSeries.length;
const filledFunnelRes = this.fillFunnel( const filledFunnelRes = this.fillFunnel(
data.map((d) => ({ level: d.level, count: d.count })), data.map((d) => ({ level: d.level, count: d.count })),
maxLevel, maxLevel,
@@ -220,7 +226,7 @@ export class FunnelService {
(acc, item, index, list) => { (acc, item, index, list) => {
const prev = list[index - 1] ?? { count: totalSessions }; const prev = list[index - 1] ?? { count: totalSessions };
const next = list[index + 1]; const next = list[index + 1];
const event = events[item.level - 1]!; const event = eventSeries[item.level - 1]!;
return [ return [
...acc, ...acc,
{ {

View File

@@ -99,7 +99,7 @@ export function transformReport(
return { return {
id: report.id, id: report.id,
projectId: report.projectId, projectId: report.projectId,
events: eventsData.map(transformReportEventItem), series: eventsData.map(transformReportEventItem),
breakdowns: report.breakdowns as IChartBreakdown[], breakdowns: report.breakdowns as IChartBreakdown[],
chartType: report.chartType, chartType: report.chartType,
lineType: (report.lineType as IChartLineType) ?? lineTypes.monotone, lineType: (report.lineType as IChartLineType) ?? lineTypes.monotone,

View File

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

View File

@@ -1,889 +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,
IChartEventItem,
IChartFormula,
IChartInput,
IChartInputWithDates,
IGetChartDataInput,
} from '@openpanel/validation';
export function withFormula(
{ formula, events }: IChartInput,
series: Awaited<ReturnType<typeof getChartSerie>>,
) {
if (!formula) {
return series;
}
if (!series || series.length === 0) {
return series;
}
if (!series[0]?.data) {
return series;
}
// Formulas always use alphabet IDs (A, B, C, etc.), not event IDs
// Group series by breakdown values (the name array)
// This allows us to match series from different events that have the same breakdown values
// Detect if we have breakdowns: when there are no breakdowns, name arrays contain event names
// When there are breakdowns, name arrays contain breakdown values (not event names)
const hasBreakdowns = series.some(
(serie) =>
serie.name.length > 0 &&
!events.some((event) => {
if (event.type === 'event') {
return (
serie.name[0] === event.name || serie.name[0] === event.displayName
);
}
return false;
}),
);
const seriesByBreakdown = new Map<string, typeof series>();
series.forEach((serie) => {
let breakdownKey: string;
if (hasBreakdowns) {
// With breakdowns: use the entire name array as the breakdown key
// The name array contains breakdown values (e.g., ["iOS"], ["Android"])
breakdownKey = serie.name.join(':::');
} else {
// Without breakdowns: group all series together regardless of event name
// This allows formulas to combine multiple events
breakdownKey = '';
}
if (!seriesByBreakdown.has(breakdownKey)) {
seriesByBreakdown.set(breakdownKey, []);
}
seriesByBreakdown.get(breakdownKey)!.push(serie);
});
// For each breakdown group, apply the formula
const result: typeof series = [];
for (const [breakdownKey, breakdownSeries] of seriesByBreakdown) {
// Group series by event to ensure we have one series per event
const seriesByEvent = new Map<string, (typeof series)[number]>();
breakdownSeries.forEach((serie) => {
const eventId = serie.event.id ?? serie.event.name;
// If we already have a series for this event in this breakdown group, skip it
// (shouldn't happen, but just in case)
if (!seriesByEvent.has(eventId)) {
seriesByEvent.set(eventId, serie);
}
});
// Get all unique dates across all series in this breakdown group
const allDates = new Set<string>();
breakdownSeries.forEach((serie) => {
serie.data.forEach((item) => {
allDates.add(item.date);
});
});
// Sort dates chronologically
const sortedDates = Array.from(allDates).sort(
(a, b) => new Date(a).getTime() - new Date(b).getTime(),
);
// Apply formula for each date, matching series by event index
const formulaData = sortedDates.map((date) => {
const scope: Record<string, number> = {};
// Build scope using alphabet IDs (A, B, C, etc.) for each event
// This matches how formulas are written (e.g., "A*100", "A/B", "A+B-C")
events.forEach((event, eventIndex) => {
const readableId = alphabetIds[eventIndex];
if (!readableId) {
throw new Error('no alphabet id for serie in withFormula');
}
// Find the series for this event in this breakdown group
// Only events (not formulas) are used in the old formula system
if (event.type !== 'event') {
scope[readableId] = 0;
return;
}
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
.filter(
(event): event is IChartEventItem & { type: 'event' } =>
event.type === 'event',
)
.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)}'`;
// Filter to only events (funnels don't support formulas)
const eventNames = payload.events
.filter((e): e is IChartEventItem & { type: 'event' } => e.type === 'event')
.map((event) => sqlstring.escape(event.name));
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 (${eventNames.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 eventItem = payload.events[item.level - 1]!;
// Funnels only work with events, not formulas
if (eventItem.type !== 'event') {
return acc;
}
const event = eventItem;
return [
...acc,
{
event: {
...event,
displayName: event.displayName ?? event.name,
},
count: item.count,
percent: (item.count / totalSessions) * 100,
dropoffCount: prev.count - item.count,
dropoffPercent: 100 - (item.count / prev.count) * 100,
previousCount: prev.count,
},
];
},
[] as {
event: IChartEvent & { displayName: string };
count: number;
percent: number;
dropoffCount: number;
dropoffPercent: number;
previousCount: number;
}[],
);
return {
totalSessions,
steps,
};
}
export async function getChartSerie(
payload: IGetChartDataInput,
timezone: string,
) {
let result = await chQuery<ISerieDataItem>(
getChartSql({ ...payload, timezone }),
{
session_timezone: timezone,
},
);
if (result.length === 0 && payload.breakdowns.length > 0) {
result = await chQuery<ISerieDataItem>(
getChartSql({
...payload,
breakdowns: [],
timezone,
}),
{
session_timezone: timezone,
},
);
}
return groupByLabels(result).map((serie) => {
return {
...serie,
event: payload.event,
};
});
}
// Normalize events to ensure they have a type field
function normalizeEventItem(
item: IChartEventItem | IChartEvent,
): IChartEventItem {
if ('type' in item) {
return item;
}
// Old format without type field - assume it's an event
return { ...item, type: 'event' as const };
}
// Calculate formula result from previous series
function calculateFormulaSeries(
formula: IChartFormula,
previousSeries: Awaited<ReturnType<typeof getChartSerie>>,
normalizedEvents: IChartEventItem[],
formulaIndex: number,
): Awaited<ReturnType<typeof getChartSerie>> {
if (!previousSeries || previousSeries.length === 0) {
return [];
}
if (!previousSeries[0]?.data) {
return [];
}
// Detect if we have breakdowns by checking if series names contain breakdown values
// (not event/formula names)
const hasBreakdowns = previousSeries.some(
(serie) =>
serie.name.length > 1 || // Multiple name parts = breakdowns
(serie.name.length === 1 &&
!normalizedEvents
.slice(0, formulaIndex)
.some(
(event) =>
event.type === 'event' &&
(serie.name[0] === event.name ||
serie.name[0] === event.displayName),
) &&
!normalizedEvents
.slice(0, formulaIndex)
.some(
(event) =>
event.type === 'formula' &&
(serie.name[0] === event.displayName ||
serie.name[0] === event.formula),
)),
);
const seriesByBreakdown = new Map<
string,
Awaited<ReturnType<typeof getChartSerie>>
>();
previousSeries.forEach((serie) => {
let breakdownKey: string;
if (hasBreakdowns) {
// With breakdowns: use the entire name array as the breakdown key
// Skip the first element (event/formula name) and use breakdown values
breakdownKey = serie.name.slice(1).join(':::');
} else {
// Without breakdowns: group all series together
// This allows formulas to combine multiple events/formulas
breakdownKey = '';
}
if (!seriesByBreakdown.has(breakdownKey)) {
seriesByBreakdown.set(breakdownKey, []);
}
seriesByBreakdown.get(breakdownKey)!.push(serie);
});
const result: Awaited<ReturnType<typeof getChartSerie>> = [];
for (const [breakdownKey, breakdownSeries] of seriesByBreakdown) {
// Group series by event index to ensure we have one series per event
const seriesByEventIndex = new Map<
number,
(typeof previousSeries)[number]
>();
breakdownSeries.forEach((serie) => {
// Find which event index this series belongs to
const eventIndex = normalizedEvents
.slice(0, formulaIndex)
.findIndex((event) => {
if (event.type === 'event') {
const eventId = event.id ?? event.name;
return (
serie.event.id === eventId || serie.event.name === event.name
);
}
return false;
});
if (eventIndex >= 0 && !seriesByEventIndex.has(eventIndex)) {
seriesByEventIndex.set(eventIndex, serie);
}
});
// Get all unique dates across all series in this breakdown group
const allDates = new Set<string>();
breakdownSeries.forEach((serie) => {
serie.data.forEach((item) => {
allDates.add(item.date);
});
});
// Sort dates chronologically
const sortedDates = Array.from(allDates).sort(
(a, b) => new Date(a).getTime() - new Date(b).getTime(),
);
// Apply formula for each date
const formulaData = sortedDates.map((date) => {
const scope: Record<string, number> = {};
// Build scope using alphabet IDs (A, B, C, etc.) for each event before this formula
normalizedEvents.slice(0, formulaIndex).forEach((event, eventIndex) => {
const readableId = alphabetIds[eventIndex];
if (!readableId) {
return;
}
if (event.type === 'event') {
const matchingSerie = seriesByEventIndex.get(eventIndex);
const dataPoint = matchingSerie?.data.find((d) => d.date === date);
scope[readableId] = dataPoint?.count ?? 0;
} else {
// If it's a formula, we need to get its calculated value
// This handles nested formulas
const formulaSerie = breakdownSeries.find(
(s) => s.event.id === event.id,
);
const dataPoint = formulaSerie?.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.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: breakdownSeries[0]?.data.find((d) => d.date === date)
?.total_count,
};
});
// Use the first series as a template
const templateSerie = breakdownSeries[0]!;
// For formulas, construct the name array:
// - Without breakdowns: use formula displayName/formula
// - With breakdowns: use formula displayName/formula as first element, then breakdown values
let formulaName: string[];
if (hasBreakdowns) {
// With breakdowns: formula name + breakdown values (skip first element which is event/formula name)
const formulaDisplayName = formula.displayName || formula.formula;
formulaName = [formulaDisplayName, ...templateSerie.name.slice(1)];
} else {
// Without breakdowns: just formula name
formulaName = [formula.displayName || formula.formula];
}
result.push({
...templateSerie,
name: formulaName,
// For formulas, create a simplified event object
// We use 'as' because formulas don't have segment/filters, but the event
// object is only used for id/name lookups later, so this is safe
event: {
id: formula.id,
name: formula.displayName || formula.formula,
displayName: formula.displayName,
segment: 'event' as const,
filters: [],
} as IChartEvent,
data: formulaData,
});
}
return result;
}
export type IGetChartSerie = Awaited<ReturnType<typeof getChartSeries>>[number];
export async function getChartSeries(
input: IChartInputWithDates,
timezone: string,
) {
// Normalize all events to have type field
const normalizedEvents = input.events.map(normalizeEventItem);
// Process events sequentially - events fetch data, formulas calculate from previous series
const allSeries: Awaited<ReturnType<typeof getChartSerie>> = [];
for (let i = 0; i < normalizedEvents.length; i++) {
const item = normalizedEvents[i]!;
if (item.type === 'event') {
// Fetch data for event
const eventSeries = await getChartSerie(
{
...input,
event: item,
},
timezone,
);
allSeries.push(...eventSeries);
} else if (item.type === 'formula') {
// Calculate formula from previous series
const formulaSeries = calculateFormulaSeries(
item,
allSeries,
normalizedEvents,
i,
);
allSeries.push(...formulaSeries);
}
}
// Apply top-level formula if present (for backward compatibility)
try {
if (input.formula) {
return withFormula(input, allSeries);
}
} catch (e) {
// If formula evaluation fails, return series as-is
}
return allSeries;
}
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,
),
);
}
// Normalize events for consistent handling
const normalizedEvents = input.events.map(normalizeEventItem);
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 = normalizedEvents.length > 1;
// Calculate metrics cache for formulas
// Map<eventIndex, Map<breakdownSignature, metrics>>
const metricsCache = new Map<
number,
Map<
string,
{
sum: number;
average: number;
min: number;
max: number;
count: number;
}
>
>();
// Initialize cache
for (let i = 0; i < normalizedEvents.length; i++) {
metricsCache.set(i, new Map());
}
// First pass: calculate standard metrics for all series and populate cache
// We iterate through series in order, but since series array is flattened, we need to be careful.
// Fortunately, events are processed sequentially, so dependencies usually appear before formulas.
// However, to be safe, we'll compute metrics for all series first.
const seriesWithMetrics = series.map((serie) => {
// Find the index of the event/formula that produced this series
const eventIndex = normalizedEvents.findIndex((event) => {
if (event.type === 'event') {
return event.id === serie.event.id || event.name === serie.event.name;
}
return event.id === serie.event.id;
});
const standardMetrics = {
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.find((item) => !!item.total_count)?.total_count || 0,
};
// Store in cache
if (eventIndex >= 0) {
const breakdownSignature = serie.name.slice(1).join(':::');
metricsCache.get(eventIndex)?.set(breakdownSignature, standardMetrics);
}
return {
serie,
eventIndex,
metrics: standardMetrics,
};
});
// Second pass: Re-calculate metrics for formulas using dependency metrics
// We iterate through normalizedEvents to process in dependency order
normalizedEvents.forEach((event, eventIndex) => {
if (event.type !== 'formula') return;
// We dont have count on formulas so use sum instead
const property = 'count';
// Iterate through all series corresponding to this formula
seriesWithMetrics.forEach((item) => {
if (item.eventIndex !== eventIndex) return;
const breakdownSignature = item.serie.name.slice(1).join(':::');
const scope: Record<string, number> = {};
// Build scope from dependency metrics
normalizedEvents.slice(0, eventIndex).forEach((depEvent, depIndex) => {
const readableId = alphabetIds[depIndex];
if (!readableId) return;
// Get metric from cache for the dependency with the same breakdown signature
const depMetrics = metricsCache.get(depIndex)?.get(breakdownSignature);
// Use sum as the default metric for formula calculation on totals
scope[readableId] = depMetrics?.[property] ?? 0;
});
// Evaluate formula
let calculatedSum: number;
try {
calculatedSum = mathjs
.parse(event.formula)
.compile()
.evaluate(scope) as number;
} catch (error) {
calculatedSum = 0;
}
// Update metrics with calculated sum
// For formulas, the "sum" metric (Total) should be the result of the formula applied to the totals
// The "average" metric usually remains average of data points, or calculatedSum / intervals
item.metrics = {
...item.metrics,
[property]:
Number.isNaN(calculatedSum) || !Number.isFinite(calculatedSum)
? 0
: round(calculatedSum, 2),
};
// Update cache with new metrics so dependent formulas can use it
metricsCache.get(eventIndex)?.set(breakdownSignature, item.metrics);
});
});
const final: FinalChart = {
series: seriesWithMetrics.map(({ serie, eventIndex, metrics }) => {
const alphaId = alphabetIds[eventIndex];
const previousSerie = previousSeries?.find(
(prevSerie) => getSerieId(prevSerie) === getSerieId(serie),
);
// Determine if this is a formula series
const isFormula = normalizedEvents[eventIndex]?.type === 'formula';
const eventItem = normalizedEvents[eventIndex];
const event = {
id: serie.event.id,
name: serie.event.displayName || serie.event.name,
};
// Construct names array based on whether it's a formula or event
let names: string[];
if (isFormula && eventItem?.type === 'formula') {
// For formulas:
// - Without breakdowns: use displayName/formula (with optional alpha ID)
// - With breakdowns: use displayName/formula + breakdown values (with optional alpha ID)
const formulaDisplayName = eventItem.displayName || eventItem.formula;
if (input.breakdowns.length === 0) {
// No breakdowns: just formula name
names = includeEventAlphaId
? [`(${alphaId}) ${formulaDisplayName}`]
: [formulaDisplayName];
} else {
// With breakdowns: formula name + breakdown values
names = includeEventAlphaId
? [`(${alphaId}) ${formulaDisplayName}`, ...serie.name.slice(1)]
: [formulaDisplayName, ...serie.name.slice(1)];
}
} else {
// For events: use existing logic
names =
input.breakdowns.length === 0 && serie.event.displayName
? [serie.event.displayName]
: includeEventAlphaId
? [`(${alphaId}) ${serie.name[0]}`, ...serie.name.slice(1)]
: serie.name;
}
return {
id: getSerieId(serie),
names,
event,
metrics: {
...metrics,
...(input.previous
? {
previous: {
sum: getPreviousMetric(
metrics.sum,
previousSerie
? sum(previousSerie?.data.map((item) => item.count))
: null,
),
average: getPreviousMetric(
metrics.average,
previousSerie
? round(
average(
previousSerie?.data.map((item) => item.count),
),
2,
)
: null,
),
min: getPreviousMetric(
metrics.sum,
previousSerie
? min(previousSerie?.data.map((item) => item.count))
: null,
),
max: getPreviousMetric(
metrics.sum,
previousSerie
? max(previousSerie?.data.map((item) => item.count))
: null,
),
count: getPreviousMetric(
metrics.count ?? 0,
previousSerie?.data[0]?.total_count ?? null,
),
},
}
: {}),
},
data: serie.data.map((item, index) => ({
date: item.date,
count: item.count ?? 0,
previous: previousSerie?.data[index]
? getPreviousMetric(
item.count ?? 0,
previousSerie?.data[index]?.count ?? null,
)
: undefined,
})),
};
}),
metrics: {
sum: 0,
average: 0,
min: 0,
max: 0,
count: undefined,
},
};
// Sort by sum
final.series = final.series
.sort((a, b) => {
if (input.chartType === 'linear') {
const sumA = a.data.reduce((acc, item) => acc + (item.count ?? 0), 0);
const sumB = b.data.reduce((acc, item) => acc + (item.count ?? 0), 0);
return sumB - sumA;
}
return (b.metrics[input.metric] ?? 0) - (a.metrics[input.metric] ?? 0);
})
.slice(offset, limit ? offset + limit : series.length);
final.metrics.sum = sum(final.series.map((item) => item.metrics.sum));
final.metrics.average = round(
average(final.series.map((item) => item.metrics.average)),
2,
);
final.metrics.min = min(final.series.map((item) => item.metrics.min));
final.metrics.max = max(final.series.map((item) => item.metrics.max));
if (input.previous) {
final.metrics.previous = {
sum: getPreviousMetric(
final.metrics.sum,
sum(final.series.map((item) => item.metrics.previous?.sum?.value ?? 0)),
),
average: getPreviousMetric(
final.metrics.average,
round(
average(
final.series.map(
(item) => item.metrics.previous?.average?.value ?? 0,
),
),
2,
),
),
min: getPreviousMetric(
final.metrics.min,
min(final.series.map((item) => item.metrics.previous?.min?.value ?? 0)),
),
max: getPreviousMetric(
final.metrics.max,
max(final.series.map((item) => item.metrics.previous?.max?.value ?? 0)),
),
count: undefined,
};
}
return final;
}

View File

@@ -24,13 +24,16 @@ import {
} from '@openpanel/db'; } from '@openpanel/db';
import { import {
zChartEvent, zChartEvent,
zChartEventFilter,
zChartInput, zChartInput,
zChartSeries,
zCriteria, zCriteria,
zRange, zRange,
zTimeInterval, zTimeInterval,
} from '@openpanel/validation'; } from '@openpanel/validation';
import { round } from '@openpanel/common'; import { round } from '@openpanel/common';
import { ChartEngine } from '@openpanel/db';
import { import {
differenceInDays, differenceInDays,
differenceInMonths, differenceInMonths,
@@ -45,7 +48,6 @@ import {
protectedProcedure, protectedProcedure,
publicProcedure, publicProcedure,
} from '../trpc'; } from '../trpc';
import { getChart } from './chart.helpers';
function utc(date: string | Date) { function utc(date: string | Date) {
if (typeof date === 'string') { if (typeof date === 'string') {
@@ -407,7 +409,8 @@ export const chartRouter = createTRPCRouter({
} }
} }
return getChart(input); // Use new chart engine
return ChartEngine.execute(input);
}), }),
cohort: protectedProcedure cohort: protectedProcedure
.input( .input(
@@ -542,151 +545,74 @@ export const chartRouter = createTRPCRouter({
.input( .input(
z.object({ z.object({
projectId: z.string(), projectId: z.string(),
event: zChartEvent,
date: z.string().describe('The date for the data point (ISO string)'), date: z.string().describe('The date for the data point (ISO string)'),
breakdowns: z
.array(
z.object({
id: z.string().optional(),
name: z.string(),
}),
)
.default([]),
interval: zTimeInterval.default('day'), interval: zTimeInterval.default('day'),
startDate: z.string(), series: zChartSeries,
endDate: z.string(), breakdowns: z.record(z.string(), z.string()).optional(),
filters: z
.array(
z.object({
id: z.string().optional(),
name: z.string(),
operator: z.string(),
value: z.array(
z.union([z.string(), z.number(), z.boolean(), z.null()]),
),
}),
)
.default([]),
limit: z.number().default(100),
}), }),
) )
.query(async ({ input }) => { .query(async ({ input }) => {
const { timezone } = await getSettingsForProject(input.projectId); const { timezone } = await getSettingsForProject(input.projectId);
const { const { projectId, date, series } = input;
projectId, const limit = 100;
event, const serie = series[0];
date,
breakdowns, if (!serie) {
interval, throw new Error('Series not found');
startDate, }
endDate,
filters, if (serie.type !== 'event') {
limit, throw new Error('Series must be an event');
} = input; }
// Build the date range for the specific interval bucket // Build the date range for the specific interval bucket
const dateObj = new Date(date); const dateObj = new Date(date);
let bucketStart: Date;
let bucketEnd: Date;
switch (interval) {
case 'minute':
bucketStart = new Date(
dateObj.getFullYear(),
dateObj.getMonth(),
dateObj.getDate(),
dateObj.getHours(),
dateObj.getMinutes(),
);
bucketEnd = new Date(bucketStart.getTime() + 60 * 1000);
break;
case 'hour':
bucketStart = new Date(
dateObj.getFullYear(),
dateObj.getMonth(),
dateObj.getDate(),
dateObj.getHours(),
);
bucketEnd = new Date(bucketStart.getTime() + 60 * 60 * 1000);
break;
case 'day':
bucketStart = new Date(
dateObj.getFullYear(),
dateObj.getMonth(),
dateObj.getDate(),
);
bucketEnd = new Date(bucketStart.getTime() + 24 * 60 * 60 * 1000);
break;
case 'week':
bucketStart = new Date(dateObj);
bucketStart.setDate(dateObj.getDate() - dateObj.getDay());
bucketStart.setHours(0, 0, 0, 0);
bucketEnd = new Date(bucketStart.getTime() + 7 * 24 * 60 * 60 * 1000);
break;
case 'month':
bucketStart = new Date(dateObj.getFullYear(), dateObj.getMonth(), 1);
bucketEnd = new Date(
dateObj.getFullYear(),
dateObj.getMonth() + 1,
1,
);
break;
default:
bucketStart = new Date(
dateObj.getFullYear(),
dateObj.getMonth(),
dateObj.getDate(),
);
bucketEnd = new Date(bucketStart.getTime() + 24 * 60 * 60 * 1000);
}
// Build query to get unique profile_ids for this time bucket // Build query to get unique profile_ids for this time bucket
const { sb, join, getWhere, getFrom, getJoins } = createSqlBuilder(); const { sb, getSql } = createSqlBuilder();
sb.where = getEventFiltersWhereClause([...event.filters, ...filters]); sb.select.profile_id = 'DISTINCT profile_id';
sb.where = getEventFiltersWhereClause(serie.filters);
sb.where.projectId = `project_id = ${sqlstring.escape(projectId)}`; sb.where.projectId = `project_id = ${sqlstring.escape(projectId)}`;
sb.where.dateRange = `created_at >= '${formatClickhouseDate(bucketStart.toISOString())}' AND created_at < '${formatClickhouseDate(bucketEnd.toISOString())}'`; sb.where.dateRange = `${clix.toStartOf('created_at', input.interval)} = ${clix.toDate(sqlstring.escape(formatClickhouseDate(dateObj)), input.interval)}`;
if (serie.name !== '*') {
if (event.name !== '*') { sb.where.eventName = `name = ${sqlstring.escape(serie.name)}`;
sb.where.eventName = `name = ${sqlstring.escape(event.name)}`;
} }
// Handle breakdowns if provided console.log('> breakdowns', input.breakdowns);
const anyBreakdownOnProfile = breakdowns.some((breakdown) => if (input.breakdowns) {
breakdown.name.startsWith('profile.'), Object.entries(input.breakdowns).forEach(([key, value]) => {
); sb.where[`breakdown_${key}`] = `${key} = ${sqlstring.escape(value)}`;
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`;
} }
// // 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 // Apply breakdown filters if provided
breakdowns.forEach((breakdown) => { // breakdowns.forEach((breakdown) => {
// This is simplified - in reality we'd need to match the breakdown value // // 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 // // For now, we'll just get all profiles for the time bucket
}); // });
// Get unique profile IDs // Get unique profile IDs
const profileIdsQuery = ` console.log('profileIdsQuery', getSql());
SELECT DISTINCT profile_id const profileIds = await chQuery<{ profile_id: string }>(getSql());
FROM ${TABLE_NAMES.events} console.log('profileIds', profileIds.length);
${getJoins()}
WHERE ${join(sb.where, ' AND ')}
AND profile_id != ''
LIMIT ${limit}
`;
const profileIds = await chQuery<{ profile_id: string }>(profileIdsQuery);
if (profileIds.length === 0) { if (profileIds.length === 0) {
return []; return [];
} }

View File

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

View File

@@ -88,9 +88,21 @@ export const zChartBreakdown = z.object({
// Support both old format (array of events without type) and new format (array of event/formula items) // 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' // Preprocess to normalize: if item has 'type' field, use discriminated union; otherwise, add type: 'event'
export const zChartEvents = z.preprocess((val) => { export const zChartSeries = z.preprocess((val) => {
if (!Array.isArray(val)) return val; if (!val) return val;
return val.map((item: any) => { 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 already has type field, return as-is
if (item && typeof item === 'object' && 'type' in item) { if (item && typeof item === 'object' && 'type' in item) {
return item; return item;
@@ -101,7 +113,14 @@ export const zChartEvents = z.preprocess((val) => {
} }
return item; return item;
}); });
}, z.array(zChartEventItem)); }, z
.array(zChartEventItem)
.describe(
'Array of series (events or formulas) to be tracked and displayed in the chart',
));
// Keep zChartEvents as an alias for backward compatibility during migration
export const zChartEvents = zChartSeries;
export const zChartBreakdowns = z.array(zChartBreakdown); export const zChartBreakdowns = z.array(zChartBreakdown);
export const zChartType = z.enum(objectToZodEnums(chartTypes)); export const zChartType = z.enum(objectToZodEnums(chartTypes));
@@ -116,7 +135,7 @@ export const zRange = z.enum(objectToZodEnums(timeWindows));
export const zCriteria = z.enum(['on_or_after', 'on']); export const zCriteria = z.enum(['on_or_after', 'on']);
export const zChartInput = z.object({ export const zChartInputBase = z.object({
chartType: zChartType chartType: zChartType
.default('linear') .default('linear')
.describe('What type of chart should be displayed'), .describe('What type of chart should be displayed'),
@@ -125,8 +144,8 @@ export const zChartInput = z.object({
.describe( .describe(
'The time interval for data aggregation (e.g., day, week, month)', 'The time interval for data aggregation (e.g., day, week, month)',
), ),
events: zChartEvents.describe( series: zChartSeries.describe(
'Array of events to be tracked and displayed in the chart', 'Array of series (events or formulas) to be tracked and displayed in the chart',
), ),
breakdowns: zChartBreakdowns breakdowns: zChartBreakdowns
.default([]) .default([])
@@ -183,7 +202,15 @@ export const zChartInput = z.object({
.describe('Time window in hours for funnel analysis'), .describe('Time window in hours for funnel analysis'),
}); });
export const zReportInput = zChartInput.extend({ export const zChartInput = z.preprocess((val) => {
if (val && typeof val === 'object' && 'events' in val && !('series' in val)) {
// Migrate old 'events' field to 'series'
return { ...val, series: val.events };
}
return val;
}, zChartInputBase);
export const zReportInput = zChartInputBase.extend({
name: z.string().describe('The user-defined name for the report'), name: z.string().describe('The user-defined name for the report'),
lineType: zLineType.describe('The visual style of the line in the chart'), lineType: zLineType.describe('The visual style of the line in the chart'),
unit: z unit: z

View File

@@ -8,6 +8,7 @@ import type {
zChartFormula, zChartFormula,
zChartInput, zChartInput,
zChartInputAI, zChartInputAI,
zChartSeries,
zChartType, zChartType,
zCriteria, zCriteria,
zLineType, zLineType,
@@ -28,6 +29,9 @@ export type IChartProps = z.infer<typeof zReportInput> & {
export type IChartEvent = z.infer<typeof zChartEvent>; export type IChartEvent = z.infer<typeof zChartEvent>;
export type IChartFormula = z.infer<typeof zChartFormula>; export type IChartFormula = z.infer<typeof zChartFormula>;
export type IChartEventItem = z.infer<typeof zChartEventItem>; export type IChartEventItem = z.infer<typeof zChartEventItem>;
export type IChartSeries = z.infer<typeof zChartSeries>;
// Backward compatibility alias
export type IChartEvents = IChartSeries;
export type IChartEventSegment = z.infer<typeof zChartEventSegment>; export type IChartEventSegment = z.infer<typeof zChartEventSegment>;
export type IChartEventFilter = IChartEvent['filters'][number]; export type IChartEventFilter = IChartEvent['filters'][number];
export type IChartEventFilterValue = export type IChartEventFilterValue =
@@ -49,7 +53,7 @@ export type IGetChartDataInput = {
projectId: string; projectId: string;
startDate: string; startDate: string;
endDate: string; endDate: string;
} & Omit<IChartInput, 'events' | 'name' | 'startDate' | 'endDate' | 'range'>; } & Omit<IChartInput, 'series' | 'name' | 'startDate' | 'endDate' | 'range'>;
export type ICriteria = z.infer<typeof zCriteria>; export type ICriteria = z.infer<typeof zCriteria>;
export type PreviousValue = export type PreviousValue =
@@ -81,6 +85,7 @@ export type IChartSerie = {
event: { event: {
id?: string; id?: string;
name: string; name: string;
breakdowns?: Record<string, string>;
}; };
metrics: Metrics; metrics: Metrics;
data: { data: {

13
pnpm-lock.yaml generated
View File

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