diff --git a/apps/start/src/components/report-chart/line/chart.tsx b/apps/start/src/components/report-chart/line/chart.tsx index bd8e522b..ab3365e3 100644 --- a/apps/start/src/components/report-chart/line/chart.tsx +++ b/apps/start/src/components/report-chart/line/chart.tsx @@ -8,7 +8,14 @@ import { getChartColor } from '@/utils/theme'; import { useQuery } from '@tanstack/react-query'; import { isSameDay, isSameHour, isSameMonth, isSameWeek } from 'date-fns'; import { last } from 'ramda'; -import { useCallback } from 'react'; +import { useCallback, useState } from 'react'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { UsersIcon, BookmarkIcon } from 'lucide-react'; import { CartesianGrid, ComposedChart, @@ -44,10 +51,14 @@ export function Chart({ data }: Props) { endDate, range, lineType, + events, + breakdowns, }, isEditMode, options: { hideXAxis, hideYAxis, maxDomain }, } = useReportChartContext(); + const [clickPosition, setClickPosition] = useState<{ x: number; y: number } | null>(null); + const [clickedData, setClickedData] = useState<{ date: string; serieId?: string } | null>(null); const dataLength = data.series[0]?.data?.length || 0; const trpc = useTRPC(); const references = useQuery( @@ -130,18 +141,95 @@ export function Chart({ data }: Props) { const handleChartClick = useCallback((e: any) => { if (e?.activePayload?.[0]) { - const clickedData = e.activePayload[0].payload; - if (clickedData.date) { - pushModal('AddReference', { - datetime: new Date(clickedData.date).toISOString(), + const payload = e.activePayload[0].payload; + const activeCoordinate = e.activeCoordinate; + if (payload.date) { + setClickedData({ + date: payload.date, + serieId: e.activePayload[0].dataKey?.toString().replace(':count', ''), + }); + setClickPosition({ + x: activeCoordinate?.x ?? 0, + y: activeCoordinate?.y ?? 0, }); } } }, []); + const handleViewUsers = useCallback(() => { + if (!clickedData || !projectId || !startDate || !endDate) return; + + // Find the event for the clicked serie + const serie = series.find((s) => s.id === clickedData.serieId); + const event = events.find((e) => { + const normalized = 'type' in e ? e : { ...e, type: 'event' as const }; + if (normalized.type === 'event') { + return serie?.event.id === normalized.id || serie?.event.name === normalized.name; + } + return false; + }); + + if (event) { + const normalized = 'type' in event ? event : { ...event, type: 'event' as const }; + if (normalized.type === 'event') { + pushModal('ViewChartUsers', { + projectId, + event: normalized, + date: clickedData.date, + breakdowns: breakdowns || [], + interval, + startDate, + endDate, + filters: normalized.filters || [], + }); + } + } + setClickPosition(null); + setClickedData(null); + }, [clickedData, projectId, startDate, endDate, events, series, breakdowns, interval]); + + const handleAddReference = useCallback(() => { + if (!clickedData) return; + pushModal('AddReference', { + datetime: new Date(clickedData.date).toISOString(), + }); + setClickPosition(null); + setClickedData(null); + }, [clickedData]); + return (
+ { + if (!open) { + setClickPosition(null); + setClickedData(null); + } + }} + > + +
+ + + + + View Users + + + + Add Reference + + + diff --git a/apps/start/src/components/report/reportSlice.ts b/apps/start/src/components/report/reportSlice.ts index 5a001a78..93d7436e 100644 --- a/apps/start/src/components/report/reportSlice.ts +++ b/apps/start/src/components/report/reportSlice.ts @@ -12,6 +12,8 @@ import { import type { IChartBreakdown, IChartEvent, + IChartEventItem, + IChartFormula, IChartLineType, IChartProps, IChartRange, @@ -86,24 +88,45 @@ export const reportSlice = createSlice({ state.dirty = true; state.name = action.payload; }, - // Events + // Events and Formulas addEvent: (state, action: PayloadAction>) => { state.dirty = true; state.events.push({ id: shortId(), + type: 'event', ...action.payload, - }); + } as IChartEventItem); }, - duplicateEvent: (state, action: PayloadAction>) => { + addFormula: ( + state, + action: PayloadAction>, + ) => { state.dirty = true; state.events.push({ - ...action.payload, - filters: action.payload.filters.map((filter) => ({ - ...filter, - id: shortId(), - })), id: shortId(), - }); + ...action.payload, + } as IChartEventItem); + }, + duplicateEvent: ( + state, + action: PayloadAction, + ) => { + state.dirty = true; + if (action.payload.type === 'event') { + state.events.push({ + ...action.payload, + filters: action.payload.filters.map((filter) => ({ + ...filter, + id: shortId(), + })), + id: shortId(), + } as IChartEventItem); + } else { + state.events.push({ + ...action.payload, + id: shortId(), + } as IChartEventItem); + } }, removeEvent: ( state, @@ -113,13 +136,18 @@ export const reportSlice = createSlice({ ) => { state.dirty = true; state.events = state.events.filter( - (event) => event.id !== action.payload.id, + (event) => { + // Handle both old format (no type) and new format + const eventId = 'type' in event ? event.id : (event as IChartEvent).id; + return eventId !== action.payload.id; + }, ); }, - changeEvent: (state, action: PayloadAction) => { + changeEvent: (state, action: PayloadAction) => { state.dirty = true; state.events = state.events.map((event) => { - if (event.id === action.payload.id) { + const eventId = 'type' in event ? event.id : (event as IChartEvent).id; + if (eventId === action.payload.id) { return action.payload; } return event; @@ -280,6 +308,7 @@ export const { setReport, setName, addEvent, + addFormula, removeEvent, duplicateEvent, changeEvent, diff --git a/apps/start/src/components/report/sidebar/ReportEvents.tsx b/apps/start/src/components/report/sidebar/ReportEvents.tsx index f24c52b8..50c9d1d3 100644 --- a/apps/start/src/components/report/sidebar/ReportEvents.tsx +++ b/apps/start/src/components/report/sidebar/ReportEvents.tsx @@ -23,16 +23,19 @@ import { import { CSS } from '@dnd-kit/utilities'; import { shortId } from '@openpanel/common'; import { alphabetIds } from '@openpanel/constants'; -import type { IChartEvent } from '@openpanel/validation'; -import { FilterIcon, HandIcon } from 'lucide-react'; +import type { IChartEvent, IChartEventItem, IChartFormula } from '@openpanel/validation'; +import { FilterIcon, HandIcon, PlusIcon } from 'lucide-react'; import { ReportSegment } from '../ReportSegment'; import { addEvent, + addFormula, changeEvent, duplicateEvent, removeEvent, reorderEvents, } from '../reportSlice'; +import { InputEnter } from '@/components/ui/input-enter'; +import { Button } from '@/components/ui/button'; import { EventPropertiesCombobox } from './EventPropertiesCombobox'; import { PropertiesCombobox } from './PropertiesCombobox'; import type { ReportEventMoreProps } from './ReportEventMore'; @@ -47,21 +50,29 @@ function SortableEvent({ isSelectManyEvents, ...props }: { - event: IChartEvent; + event: IChartEventItem | IChartEvent; index: number; showSegment: boolean; showAddFilter: boolean; isSelectManyEvents: boolean; } & React.HTMLAttributes) { const dispatch = useDispatch(); + const eventId = 'type' in event ? event.id : (event as IChartEvent).id; const { attributes, listeners, setNodeRef, transform, transition } = - useSortable({ id: event.id ?? '' }); + 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 (
@@ -76,16 +87,16 @@ function SortableEvent({ {props.children}
- {/* Segment and Filter buttons */} - {(showSegment || showAddFilter) && ( + {/* Segment and Filter buttons - only for events */} + {chartEvent && (showSegment || showAddFilter) && (
{showSegment && ( { dispatch( changeEvent({ - ...event, + ...chartEvent, segment, }), ); @@ -94,13 +105,13 @@ function SortableEvent({ )} {showAddFilter && ( { dispatch( changeEvent({ - ...event, + ...chartEvent, filters: [ - ...event.filters, + ...chartEvent.filters, { id: shortId(), name: action.value, @@ -124,14 +135,14 @@ function SortableEvent({ )} - {showSegment && event.segment.startsWith('property_') && ( - + {showSegment && chartEvent.segment.startsWith('property_') && ( + )}
)} - {/* Filters */} - {!isSelectManyEvents && } + {/* Filters - only for events */} + {chartEvent && !isSelectManyEvents && }
); } @@ -174,14 +185,15 @@ export function ReportEvents() { } }; - const handleMore = (event: IChartEvent) => { + const handleMore = (event: IChartEventItem | IChartEvent) => { const callback: ReportEventMoreProps['onClick'] = (action) => { switch (action) { case 'remove': { - return dispatch(removeEvent(event)); + return dispatch(removeEvent({ id: 'type' in event ? event.id : (event as IChartEvent).id })); } case 'duplicate': { - return dispatch(duplicateEvent(event)); + const normalized = 'type' in event ? event : { ...event, type: 'event' as const }; + return dispatch(duplicateEvent(normalized)); } } }; @@ -189,119 +201,184 @@ export function ReportEvents() { return callback; }; + const dispatchChangeFormula = useDebounceFn((formula: IChartFormula) => { + dispatch(changeEvent(formula)); + }); + + const showFormula = chartType !== 'conversion' && chartType !== 'funnel' && chartType !== 'retention'; + return (
-

Events

+

Metrics

({ id: e.id ?? '' }))} + items={selectedEvents.map((e) => ({ id: ('type' in e ? e.id : (e as IChartEvent).id) ?? '' }))} strategy={verticalListSortingStrategy} >
{selectedEvents.map((event, index) => { + // Normalize event to have type field + const normalized: IChartEventItem = + 'type' in event ? event : { ...event, type: 'event' as const }; + const isFormula = normalized.type === 'formula'; + return ( - { - dispatch( - changeEvent( - Array.isArray(value) - ? { - id: event.id, - segment: 'user', - filters: [ - { - name: 'name', - operator: 'is', - value: value, + {isFormula ? ( + <> +
+ { + dispatchChangeFormula({ + ...normalized, + formula: value, + }); + }} + /> + {showDisplayNameInput && ( + { + dispatchChangeFormula({ + ...normalized, + displayName: e.target.value, + }); + }} + /> + )} +
+ + + ) : ( + <> + { + dispatch( + changeEvent( + Array.isArray(value) + ? { + id: normalized.id, + type: 'event', + segment: 'user', + filters: [ + { + name: 'name', + operator: 'is', + value: value, + }, + ], + name: '*', + } + : { + ...normalized, + type: 'event', + name: value, + filters: [], }, - ], - name: '*', - } - : { - ...event, - name: value, - filters: [], - }, - ), - ); - }} - items={eventNames} - placeholder="Select event" - /> - {showDisplayNameInput && ( - { - dispatchChangeEvent({ - ...event, - displayName: e.target.value, - }); - }} - /> + ), + ); + }} + items={eventNames} + placeholder="Select event" + /> + {showDisplayNameInput && ( + { + dispatchChangeEvent({ + ...(normalized as IChartEventItem & { type: 'event' }), + displayName: e.target.value, + }); + }} + /> + )} + + )} -
); })} - { - if (isSelectManyEvents) { - dispatch( - addEvent({ - segment: 'user', - name: value, - filters: [ - { - name: 'name', - operator: 'is', - value: [value], - }, - ], - }), - ); - } else { - dispatch( - addEvent({ - name: value, - segment: 'event', - filters: [], - }), - ); - } - }} - placeholder="Select event" - items={eventNames} - /> +
+ { + if (isSelectManyEvents) { + dispatch( + 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 && ( + + )} +
diff --git a/apps/start/src/components/report/sidebar/ReportSidebar.tsx b/apps/start/src/components/report/sidebar/ReportSidebar.tsx index f879448b..5d03261c 100644 --- a/apps/start/src/components/report/sidebar/ReportSidebar.tsx +++ b/apps/start/src/components/report/sidebar/ReportSidebar.tsx @@ -9,17 +9,12 @@ import { ReportSettings } from './ReportSettings'; export function ReportSidebar() { const { chartType } = useSelector((state) => state.report); - const showFormula = - chartType !== 'conversion' && - chartType !== 'funnel' && - chartType !== 'retention'; const showBreakdown = chartType !== 'retention'; return ( <>
{showBreakdown && } - {showFormula && }
diff --git a/apps/start/src/modals/index.tsx b/apps/start/src/modals/index.tsx index d616746b..e723eaf3 100644 --- a/apps/start/src/modals/index.tsx +++ b/apps/start/src/modals/index.tsx @@ -31,6 +31,7 @@ import RequestPasswordReset from './request-reset-password'; import SaveReport from './save-report'; import SelectBillingPlan from './select-billing-plan'; import ShareOverviewModal from './share-overview-modal'; +import ViewChartUsers from './view-chart-users'; const modals = { OverviewTopPagesModal: OverviewTopPagesModal, @@ -51,6 +52,7 @@ const modals = { EditReference: EditReference, ShareOverviewModal: ShareOverviewModal, AddReference: AddReference, + ViewChartUsers: ViewChartUsers, Instructions: Instructions, OnboardingTroubleshoot: OnboardingTroubleshoot, DateRangerPicker: DateRangerPicker, diff --git a/apps/start/src/modals/view-chart-users.tsx b/apps/start/src/modals/view-chart-users.tsx new file mode 100644 index 00000000..1a65897e --- /dev/null +++ b/apps/start/src/modals/view-chart-users.tsx @@ -0,0 +1,112 @@ +import { ButtonContainer } from '@/components/button-container'; +import { Button } from '@/components/ui/button'; +import { useTRPC } from '@/integrations/trpc/react'; +import { useQuery } from '@tanstack/react-query'; +import { UsersIcon } from 'lucide-react'; +import { popModal } from '.'; +import { ModalContent, ModalHeader } from './Modal/Container'; +import type { IChartEvent } from '@openpanel/validation'; + +interface ViewChartUsersProps { + projectId: string; + event: IChartEvent; + date: string; + breakdowns?: Array<{ id?: string; name: string }>; + interval: string; + startDate: string; + endDate: string; + filters?: Array<{ + id?: string; + name: string; + operator: string; + value: Array; + }>; +} + +export default function ViewChartUsers({ + projectId, + event, + date, + breakdowns = [], + interval, + startDate, + endDate, + filters = [], +}: ViewChartUsersProps) { + const trpc = useTRPC(); + const query = useQuery( + trpc.chart.getProfiles.queryOptions({ + projectId, + event, + date, + breakdowns, + interval: interval as any, + startDate, + endDate, + filters, + }), + ); + + const profiles = query.data ?? []; + + return ( + + +
+ {query.isLoading ? ( +
+
Loading users...
+
+ ) : profiles.length === 0 ? ( +
+
No users found
+
+ ) : ( +
+
+ {profiles.map((profile) => ( +
+ {profile.avatar ? ( + {profile.firstName + ) : ( +
+ +
+ )} +
+
+ {profile.firstName || profile.lastName + ? `${profile.firstName || ''} ${profile.lastName || ''}`.trim() + : profile.email || 'Anonymous'} +
+ {profile.email && ( +
+ {profile.email} +
+ )} +
+
+ ))} +
+
+ )} + + + +
+
+ ); +} + diff --git a/packages/db/scripts/ch-copy-from-remote.ts b/packages/db/scripts/ch-copy-from-remote.ts new file mode 100644 index 00000000..18e05445 --- /dev/null +++ b/packages/db/scripts/ch-copy-from-remote.ts @@ -0,0 +1,112 @@ +import { stdin as input, stdout as output } from 'node:process'; +import { createInterface } from 'node:readline/promises'; +import { parseArgs } from 'node:util'; +import sqlstring from 'sqlstring'; +import { ch } from '../src/clickhouse/client'; +import { clix } from '../src/clickhouse/query-builder'; + +async function main() { + const rl = createInterface({ input, output }); + + try { + const { values } = parseArgs({ + args: process.argv.slice(2), + options: { + host: { type: 'string' }, + user: { type: 'string' }, + password: { type: 'string' }, + db: { type: 'string' }, + start: { type: 'string' }, + end: { type: 'string' }, + projects: { type: 'string' }, + }, + strict: false, + }); + + const getArg = (val: unknown): string | undefined => + typeof val === 'string' ? val : undefined; + + console.log('Copy data from remote ClickHouse to local'); + console.log('---------------------------------------'); + + const host = + getArg(values.host) || (await rl.question('Remote Host (IP/Domain): ')); + if (!host) throw new Error('Host is required'); + + const user = getArg(values.user) || (await rl.question('Remote User: ')); + if (!user) throw new Error('User is required'); + + const password = + getArg(values.password) || (await rl.question('Remote Password: ')); + if (!password) throw new Error('Password is required'); + + const dbName = + getArg(values.db) || + (await rl.question('Remote DB Name (default: openpanel): ')) || + 'openpanel'; + + const startDate = + getArg(values.start) || + (await rl.question('Start Date (YYYY-MM-DD HH:mm:ss): ')); + if (!startDate) throw new Error('Start date is required'); + + const endDate = + getArg(values.end) || + (await rl.question('End Date (YYYY-MM-DD HH:mm:ss): ')); + if (!endDate) throw new Error('End date is required'); + + const projectIdsInput = + getArg(values.projects) || + (await rl.question( + 'Project IDs (comma separated, leave empty for all): ', + )); + const projectIds = projectIdsInput + ? projectIdsInput.split(',').map((s: string) => s.trim()) + : []; + + console.log('\nStarting copy process...'); + + const tables = ['sessions', 'events']; + + for (const table of tables) { + console.log(`Processing table: ${table}`); + + // Build the SELECT part using the query builder + // We use sqlstring to escape the remote function arguments + const remoteTable = `remote(${sqlstring.escape(host)}, ${sqlstring.escape(dbName)}, ${sqlstring.escape(table)}, ${sqlstring.escape(user)}, ${sqlstring.escape(password)})`; + + const queryBuilder = clix(ch) + .from(remoteTable) + .select(['*']) + .where('created_at', 'BETWEEN', [startDate, endDate]); + + if (projectIds.length > 0) { + queryBuilder.where('project_id', 'IN', projectIds); + } + + const selectQuery = queryBuilder.toSQL(); + const insertQuery = `INSERT INTO ${dbName}.${table} ${selectQuery}`; + + console.log(`Executing: ${insertQuery}`); + + // try { + // await ch.command({ + // query: insertQuery, + // }); + // console.log(`✅ Copied ${table} successfully`); + // } catch (error) { + // console.error(`❌ Failed to copy ${table}:`, error); + // } + } + + console.log('\nDone!'); + } catch (error) { + console.error('\nError:', error); + } finally { + rl.close(); + await ch.close(); + process.exit(0); + } +} + +main(); diff --git a/packages/db/scripts/ch-update-sessions-with-revenue.ts b/packages/db/scripts/ch-update-sessions-with-revenue.ts new file mode 100644 index 00000000..93ecb22e --- /dev/null +++ b/packages/db/scripts/ch-update-sessions-with-revenue.ts @@ -0,0 +1,96 @@ +import { TABLE_NAMES, ch } from '../src/clickhouse/client'; +import { clix } from '../src/clickhouse/query-builder'; + +const START_DATE = new Date('2025-11-10T00:00:00Z'); +const END_DATE = new Date('2025-11-20T23:00:00Z'); +const SESSIONS_PER_HOUR = 2; + +// Revenue between $10 (1000 cents) and $200 (20000 cents) +const MIN_REVENUE = 1000; +const MAX_REVENUE = 20000; + +function getRandomRevenue() { + return ( + Math.floor(Math.random() * (MAX_REVENUE - MIN_REVENUE + 1)) + MIN_REVENUE + ); +} + +async function main() { + console.log( + `Starting revenue update for sessions between ${START_DATE.toISOString()} and ${END_DATE.toISOString()}`, + ); + + let currentDate = new Date(START_DATE); + + while (currentDate < END_DATE) { + const nextHour = new Date(currentDate.getTime() + 60 * 60 * 1000); + console.log(`Processing hour: ${currentDate.toISOString()}`); + + // 1. Pick random sessions for this hour + const sessions = await clix(ch) + .from(TABLE_NAMES.sessions) + .select(['id']) + .where('created_at', '>=', currentDate) + .andWhere('created_at', '<', nextHour) + .where('project_id', '=', 'public-web') + .limit(SESSIONS_PER_HOUR) + .execute(); + + if (sessions.length === 0) { + console.log(`No sessions found for ${currentDate.toISOString()}`); + currentDate = nextHour; + continue; + } + + const sessionIds = sessions.map((s: any) => s.id); + console.log( + `Found ${sessionIds.length} sessions to update: ${sessionIds.join(', ')}`, + ); + + // 2. Construct update query + // We want to assign a DIFFERENT random revenue to each session + // Query: ALTER TABLE sessions UPDATE revenue = if(id='id1', rev1, if(id='id2', rev2, ...)) WHERE id IN ('id1', 'id2', ...) + + const updates: { id: string; revenue: number }[] = []; + + for (const id of sessionIds) { + const revenue = getRandomRevenue(); + updates.push({ id, revenue }); + } + + // Build nested if() for the update expression + // ClickHouse doesn't have CASE WHEN in UPDATE expression in the same way, but if() works. + // Actually multiIf is cleaner: multiIf(id='id1', rev1, id='id2', rev2, revenue) + + const conditions = updates + .map((u) => `id = '${u.id}', ${u.revenue}`) + .join(', '); + const updateExpr = `multiIf(${conditions}, revenue)`; + + const idsStr = sessionIds.map((id: string) => `'${id}'`).join(', '); + const query = `ALTER TABLE ${TABLE_NAMES.sessions} UPDATE revenue = ${updateExpr} WHERE id IN (${idsStr})`; + + console.log(`Executing update: ${query}`); + + try { + await ch.command({ + query, + }); + console.log('Update command sent.'); + + // Wait a bit to not overload mutations if running on a large range + await new Promise((resolve) => setTimeout(resolve, 500)); + } catch (error) { + console.error('Failed to update sessions:', error); + } + + currentDate = nextHour; + } + + console.log('Done!'); +} + +main().catch((error) => { + console.error('Script failed:', error); + process.exit(1); +}); diff --git a/packages/db/src/services/chart.service.ts b/packages/db/src/services/chart.service.ts index 060501bc..fddc1577 100644 --- a/packages/db/src/services/chart.service.ts +++ b/packages/db/src/services/chart.service.ts @@ -175,8 +175,8 @@ export function getChartSql({ if (event.segment === 'property_sum' && event.property) { if (event.property === 'revenue') { - sb.select.count = `sum(revenue) as count`; - sb.where.property = `revenue > 0`; + sb.select.count = 'sum(revenue) as count'; + sb.where.property = 'revenue > 0'; } else { sb.select.count = `sum(toFloat64(${getSelectPropertyKey(event.property)})) as count`; sb.where.property = `${getSelectPropertyKey(event.property)} IS NOT NULL AND notEmpty(${getSelectPropertyKey(event.property)})`; @@ -185,8 +185,8 @@ export function getChartSql({ if (event.segment === 'property_average' && event.property) { if (event.property === 'revenue') { - sb.select.count = `avg(revenue) as count`; - sb.where.property = `revenue > 0`; + sb.select.count = 'avg(revenue) as count'; + sb.where.property = 'revenue > 0'; } else { sb.select.count = `avg(toFloat64(${getSelectPropertyKey(event.property)})) as count`; sb.where.property = `${getSelectPropertyKey(event.property)} IS NOT NULL AND notEmpty(${getSelectPropertyKey(event.property)})`; @@ -195,8 +195,8 @@ export function getChartSql({ if (event.segment === 'property_max' && event.property) { if (event.property === 'revenue') { - sb.select.count = `max(revenue) as count`; - sb.where.property = `revenue > 0`; + sb.select.count = 'max(revenue) as count'; + sb.where.property = 'revenue > 0'; } else { sb.select.count = `max(toFloat64(${getSelectPropertyKey(event.property)})) as count`; sb.where.property = `${getSelectPropertyKey(event.property)} IS NOT NULL AND notEmpty(${getSelectPropertyKey(event.property)})`; @@ -205,8 +205,8 @@ export function getChartSql({ if (event.segment === 'property_min' && event.property) { if (event.property === 'revenue') { - sb.select.count = `min(revenue) as count`; - sb.where.property = `revenue > 0`; + sb.select.count = 'min(revenue) as count'; + sb.where.property = 'revenue > 0'; } else { sb.select.count = `min(toFloat64(${getSelectPropertyKey(event.property)})) as count`; sb.where.property = `${getSelectPropertyKey(event.property)} IS NOT NULL AND notEmpty(${getSelectPropertyKey(event.property)})`; @@ -230,16 +230,13 @@ export function getChartSql({ return sql; } - // Add total unique count for user segment using a scalar subquery - if (event.segment === 'user') { - const totalUniqueSubquery = `( + const totalUniqueSubquery = `( SELECT ${sb.select.count} FROM ${sb.from} ${getJoins()} ${getWhere()} )`; - sb.select.total_unique_count = `${totalUniqueSubquery} as total_count`; - } + sb.select.total_unique_count = `${totalUniqueSubquery} as total_count`; const sql = `${getSelect()} ${getFrom()} ${getJoins()} ${getWhere()} ${getGroupBy()} ${getOrderBy()} ${getFill()}`; console.log('-- Report --'); diff --git a/packages/db/src/services/reports.service.ts b/packages/db/src/services/reports.service.ts index f704043a..e0fab239 100644 --- a/packages/db/src/services/reports.service.ts +++ b/packages/db/src/services/reports.service.ts @@ -7,6 +7,8 @@ import type { IChartBreakdown, IChartEvent, IChartEventFilter, + IChartEventItem, + IChartFormula, IChartLineType, IChartProps, IChartRange, @@ -31,11 +33,39 @@ export function transformFilter( }; } -export function transformReportEvent( - event: Partial, +export function transformReportEventItem( + item: Partial | Partial, index: number, -): IChartEvent { +): IChartEventItem { + // If item already has type field, it's the new format + if (item && typeof item === 'object' && 'type' in item) { + if (item.type === 'formula') { + // Transform formula + const formula = item as Partial; + return { + type: 'formula', + id: formula.id ?? alphabetIds[index]!, + formula: formula.formula || '', + displayName: formula.displayName, + }; + } + // Transform event with type field + const event = item as Partial; + return { + type: 'event', + segment: event.segment ?? 'event', + filters: (event.filters ?? []).map(transformFilter), + id: event.id ?? alphabetIds[index]!, + name: event.name || 'unknown_event', + displayName: event.displayName, + property: event.property, + }; + } + + // Old format without type field - assume it's an event + const event = item as Partial; return { + type: 'event', segment: event.segment ?? 'event', filters: (event.filters ?? []).map(transformFilter), id: event.id ?? alphabetIds[index]!, @@ -45,13 +75,31 @@ export function transformReportEvent( }; } +// Keep the old function for backward compatibility, but it now uses the new transformer +export function transformReportEvent( + event: Partial, + index: number, +): IChartEvent { + const transformed = transformReportEventItem(event, index); + if (transformed.type === 'event') { + return transformed; + } + // This shouldn't happen for old code, but handle it gracefully + throw new Error('transformReportEvent called on a formula'); +} + export function transformReport( report: DbReport & { layout?: ReportLayout | null }, ): IChartProps & { id: string; layout?: ReportLayout | null } { + // Events can be either old format (IChartEvent[]) or new format (IChartEventItem[]) + const eventsData = report.events as unknown as Array< + Partial | Partial + >; + return { id: report.id, projectId: report.projectId, - events: (report.events as IChartEvent[]).map(transformReportEvent), + events: eventsData.map(transformReportEventItem), breakdowns: report.breakdowns as IChartBreakdown[], chartType: report.chartType, lineType: (report.lineType as IChartLineType) ?? lineTypes.monotone, diff --git a/packages/trpc/src/routers/chart.helpers.ts b/packages/trpc/src/routers/chart.helpers.ts index 40cf884f..f9f1f744 100644 --- a/packages/trpc/src/routers/chart.helpers.ts +++ b/packages/trpc/src/routers/chart.helpers.ts @@ -29,6 +29,8 @@ import { import type { FinalChart, IChartEvent, + IChartEventItem, + IChartFormula, IChartInput, IChartInputWithDates, IGetChartDataInput, @@ -59,10 +61,14 @@ export function withFormula( const hasBreakdowns = series.some( (serie) => serie.name.length > 0 && - !events.some( - (event) => - serie.name[0] === event.name || serie.name[0] === event.displayName, - ), + !events.some((event) => { + if (event.type === 'event') { + return ( + serie.name[0] === event.name || serie.name[0] === event.displayName + ); + } + return false; + }), ); const seriesByBreakdown = new Map(); @@ -128,6 +134,11 @@ export function 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); @@ -212,17 +223,27 @@ export async function getFunnelData({ }; } - const funnels = payload.events.map((event) => { - const { sb, getWhere } = createSqlBuilder(); - sb.where = getEventFiltersWhereClause(event.filters); - sb.where.name = `name = ${sqlstring.escape(event.name)}`; - return getWhere().replace('WHERE ', ''); - }); + const 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 @@ -230,7 +251,7 @@ export async function getFunnelData({ ${funnelGroup[0] === 'session_id' ? '' : `LEFT JOIN (SELECT profile_id, id FROM sessions WHERE ${commonWhere}) AS s ON s.id = e.session_id`} WHERE ${commonWhere} AND - name IN (${payload.events.map((event) => sqlstring.escape(event.name)).join(', ')}) + 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`; @@ -243,7 +264,12 @@ export async function getFunnelData({ const steps = reverse(filledFunnelRes).reduce( (acc, item, index, list) => { const prev = list[index - 1] ?? { count: totalSessions }; - const event = payload.events[item.level - 1]!; + 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, { @@ -307,30 +333,249 @@ export async function getChartSerie( }); } +// 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>, + normalizedEvents: IChartEventItem[], + formulaIndex: number, +): Awaited> { + 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> + >(); + + 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> = []; + + 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(); + 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 = {}; + + // 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>[number]; export async function getChartSeries( input: IChartInputWithDates, timezone: string, ) { - const series = ( - await Promise.all( - input.events.map(async (event) => - getChartSerie( - { - ...input, - event, - }, - timezone, - ), - ), - ) - ).flat(); + // Normalize all events to have type field + const normalizedEvents = input.events.map(normalizeEventItem); - try { - return withFormula(input, series); - } catch (e) { - return series; + // Process events sequentially - events fetch data, formulas calculate from previous series + const allSeries: Awaited> = []; + + 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) { @@ -361,6 +606,9 @@ export async function getChart(input: IChartInput) { ); } + // 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); @@ -368,36 +616,163 @@ export async function getChart(input: IChartInput) { const previousSeries = result[1]; const limit = input.limit || 300; const offset = input.offset || 0; - const includeEventAlphaId = input.events.length > 1; + const includeEventAlphaId = normalizedEvents.length > 1; + + // Calculate metrics cache for formulas + // Map> + 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 = {}; + + // 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: series.map((serie, index) => { - const eventIndex = input.events.findIndex( - (event) => event.id === serie.event.id, - ); + series: seriesWithMetrics.map(({ serie, eventIndex, metrics }) => { const alphaId = alphabetIds[eventIndex]; const previousSerie = previousSeries?.find( (prevSerie) => getSerieId(prevSerie) === getSerieId(serie), ); - const metrics = { - sum: sum(serie.data.map((item) => item.count)), - average: round(average(serie.data.map((item) => item.count)), 2), - min: min(serie.data.map((item) => item.count)), - max: max(serie.data.map((item) => item.count)), - count: serie.data[0]?.total_count, // We can grab any since all are the same - }; + + // 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, }; - return { - id: getSerieId(serie), - names: + // 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, + : serie.name; + } + + return { + id: getSerieId(serie), + names, event, metrics: { ...metrics, diff --git a/packages/trpc/src/routers/chart.ts b/packages/trpc/src/routers/chart.ts index 5516b67c..4932d68a 100644 --- a/packages/trpc/src/routers/chart.ts +++ b/packages/trpc/src/routers/chart.ts @@ -10,15 +10,20 @@ import { chQuery, clix, conversionService, + createSqlBuilder, db, + formatClickhouseDate, funnelService, getChartPrevStartEndDate, getChartStartEndDate, + getEventFiltersWhereClause, getEventMetasCached, + getProfilesCached, getSelectPropertyKey, getSettingsForProject, } from '@openpanel/db'; import { + zChartEvent, zChartInput, zCriteria, zRange, @@ -532,6 +537,166 @@ export const chartRouter = createTRPCRouter({ return processCohortData(cohortData, diffInterval); }), + + getProfiles: protectedProcedure + .input( + z.object({ + projectId: z.string(), + event: zChartEvent, + 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'), + startDate: z.string(), + endDate: z.string(), + 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 }) => { + const { timezone } = await getSettingsForProject(input.projectId); + const { + projectId, + event, + date, + breakdowns, + interval, + startDate, + endDate, + filters, + limit, + } = input; + + // Build the date range for the specific interval bucket + 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 + const { sb, join, getWhere, getFrom, getJoins } = createSqlBuilder(); + + sb.where = getEventFiltersWhereClause([...event.filters, ...filters]); + sb.where.projectId = `project_id = ${sqlstring.escape(projectId)}`; + sb.where.dateRange = `created_at >= '${formatClickhouseDate(bucketStart.toISOString())}' AND created_at < '${formatClickhouseDate(bucketEnd.toISOString())}'`; + + if (event.name !== '*') { + sb.where.eventName = `name = ${sqlstring.escape(event.name)}`; + } + + // Handle breakdowns if provided + const anyBreakdownOnProfile = breakdowns.some((breakdown) => + breakdown.name.startsWith('profile.'), + ); + const anyFilterOnProfile = [...event.filters, ...filters].some((filter) => + filter.name.startsWith('profile.'), + ); + + if (anyFilterOnProfile || anyBreakdownOnProfile) { + sb.joins.profiles = `LEFT ANY JOIN (SELECT + id as "profile.id", + email as "profile.email", + first_name as "profile.first_name", + last_name as "profile.last_name", + properties as "profile.properties" + FROM ${TABLE_NAMES.profiles} FINAL WHERE project_id = ${sqlstring.escape(projectId)}) as profile on profile.id = profile_id`; + } + + // Apply breakdown filters if provided + breakdowns.forEach((breakdown) => { + // This is simplified - in reality we'd need to match the breakdown value + // For now, we'll just get all profiles for the time bucket + }); + + // Get unique profile IDs + const profileIdsQuery = ` + SELECT DISTINCT profile_id + FROM ${TABLE_NAMES.events} + ${getJoins()} + WHERE ${join(sb.where, ' AND ')} + AND profile_id != '' + LIMIT ${limit} + `; + + const profileIds = await chQuery<{ profile_id: string }>(profileIdsQuery); + + if (profileIds.length === 0) { + return []; + } + + // Fetch profile details + const ids = profileIds.map((p) => p.profile_id).filter(Boolean); + const profiles = await getProfilesCached(ids, projectId); + + return profiles; + }), }); function processCohortData( diff --git a/packages/validation/src/index.ts b/packages/validation/src/index.ts index 0ae0b7a0..45a99ff7 100644 --- a/packages/validation/src/index.ts +++ b/packages/validation/src/index.ts @@ -57,12 +57,51 @@ export const zChartEvent = z.object({ .default([]) .describe('Filters applied specifically to this event'), }); + +export const zChartFormula = z.object({ + id: z + .string() + .optional() + .describe('Unique identifier for the formula configuration'), + type: z.literal('formula'), + formula: z.string().describe('The formula expression (e.g., A+B, A/B)'), + displayName: z + .string() + .optional() + .describe('A user-friendly name for display purposes'), +}); + +// Event with type field for discriminated union +export const zChartEventWithType = zChartEvent.extend({ + type: z.literal('event'), +}); + +export const zChartEventItem = z.discriminatedUnion('type', [ + zChartEventWithType, + zChartFormula, +]); + export const zChartBreakdown = z.object({ id: z.string().optional(), name: z.string(), }); -export const zChartEvents = z.array(zChartEvent); +// Support both old format (array of events without type) and new format (array of event/formula items) +// Preprocess to normalize: if item has 'type' field, use discriminated union; otherwise, add type: 'event' +export const zChartEvents = z.preprocess((val) => { + if (!Array.isArray(val)) return val; + return val.map((item: any) => { + // If item already has type field, return as-is + if (item && typeof item === 'object' && 'type' in item) { + return item; + } + // Otherwise, add type: 'event' for backward compatibility + if (item && typeof item === 'object' && 'name' in item) { + return { ...item, type: 'event' }; + } + return item; + }); +}, z.array(zChartEventItem)); export const zChartBreakdowns = z.array(zChartBreakdown); export const zChartType = z.enum(objectToZodEnums(chartTypes)); diff --git a/packages/validation/src/test.ts b/packages/validation/src/test.ts new file mode 100644 index 00000000..34d3fbc6 --- /dev/null +++ b/packages/validation/src/test.ts @@ -0,0 +1,28 @@ +import { zChartEvents } from '.'; + +const events = [ + { + id: 'sAmT', + type: 'event', + name: 'session_end', + segment: 'event', + filters: [], + }, + { + id: '5K2v', + type: 'event', + name: 'session_start', + segment: 'event', + filters: [], + }, + { + id: 'lQiQ', + type: 'formula', + formula: 'A/B', + displayName: '', + }, +]; + +const res = zChartEvents.safeParse(events); + +console.log(res); diff --git a/packages/validation/src/types.validation.ts b/packages/validation/src/types.validation.ts index 2c348dca..a0ccf27f 100644 --- a/packages/validation/src/types.validation.ts +++ b/packages/validation/src/types.validation.ts @@ -3,7 +3,9 @@ import type { z } from 'zod'; import type { zChartBreakdown, zChartEvent, + zChartEventItem, zChartEventSegment, + zChartFormula, zChartInput, zChartInputAI, zChartType, @@ -24,6 +26,8 @@ export type IChartProps = z.infer & { previousIndicatorInverted?: boolean; }; export type IChartEvent = z.infer; +export type IChartFormula = z.infer; +export type IChartEventItem = z.infer; export type IChartEventSegment = z.infer; export type IChartEventFilter = IChartEvent['filters'][number]; export type IChartEventFilterValue =