formulas
This commit is contained in:
@@ -8,7 +8,14 @@ import { getChartColor } from '@/utils/theme';
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { isSameDay, isSameHour, isSameMonth, isSameWeek } from 'date-fns';
|
import { isSameDay, isSameHour, isSameMonth, isSameWeek } from 'date-fns';
|
||||||
import { last } from 'ramda';
|
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 {
|
import {
|
||||||
CartesianGrid,
|
CartesianGrid,
|
||||||
ComposedChart,
|
ComposedChart,
|
||||||
@@ -44,10 +51,14 @@ export function Chart({ data }: Props) {
|
|||||||
endDate,
|
endDate,
|
||||||
range,
|
range,
|
||||||
lineType,
|
lineType,
|
||||||
|
events,
|
||||||
|
breakdowns,
|
||||||
},
|
},
|
||||||
isEditMode,
|
isEditMode,
|
||||||
options: { hideXAxis, hideYAxis, maxDomain },
|
options: { hideXAxis, hideYAxis, maxDomain },
|
||||||
} = useReportChartContext();
|
} = 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 dataLength = data.series[0]?.data?.length || 0;
|
||||||
const trpc = useTRPC();
|
const trpc = useTRPC();
|
||||||
const references = useQuery(
|
const references = useQuery(
|
||||||
@@ -130,18 +141,95 @@ export function Chart({ data }: Props) {
|
|||||||
|
|
||||||
const handleChartClick = useCallback((e: any) => {
|
const handleChartClick = useCallback((e: any) => {
|
||||||
if (e?.activePayload?.[0]) {
|
if (e?.activePayload?.[0]) {
|
||||||
const clickedData = e.activePayload[0].payload;
|
const payload = e.activePayload[0].payload;
|
||||||
if (clickedData.date) {
|
const activeCoordinate = e.activeCoordinate;
|
||||||
pushModal('AddReference', {
|
if (payload.date) {
|
||||||
datetime: new Date(clickedData.date).toISOString(),
|
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 (
|
return (
|
||||||
<ReportChartTooltip.TooltipProvider references={references.data}>
|
<ReportChartTooltip.TooltipProvider references={references.data}>
|
||||||
<div className={cn('h-full w-full', isEditMode && 'card p-4')}>
|
<div className={cn('h-full w-full', isEditMode && 'card p-4')}>
|
||||||
|
<DropdownMenu
|
||||||
|
open={clickPosition !== null}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setClickPosition(null);
|
||||||
|
setClickedData(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: clickPosition?.x ?? -9999,
|
||||||
|
top: clickPosition?.y ?? -9999,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent>
|
||||||
|
<DropdownMenuItem onClick={handleViewUsers}>
|
||||||
|
<UsersIcon size={16} className="mr-2" />
|
||||||
|
View Users
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={handleAddReference}>
|
||||||
|
<BookmarkIcon size={16} className="mr-2" />
|
||||||
|
Add Reference
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
<ResponsiveContainer>
|
<ResponsiveContainer>
|
||||||
<ComposedChart data={rechartData} onClick={handleChartClick}>
|
<ComposedChart data={rechartData} onClick={handleChartClick}>
|
||||||
<Customized component={calcStrokeDasharray} />
|
<Customized component={calcStrokeDasharray} />
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import {
|
|||||||
import type {
|
import type {
|
||||||
IChartBreakdown,
|
IChartBreakdown,
|
||||||
IChartEvent,
|
IChartEvent,
|
||||||
|
IChartEventItem,
|
||||||
|
IChartFormula,
|
||||||
IChartLineType,
|
IChartLineType,
|
||||||
IChartProps,
|
IChartProps,
|
||||||
IChartRange,
|
IChartRange,
|
||||||
@@ -86,24 +88,45 @@ export const reportSlice = createSlice({
|
|||||||
state.dirty = true;
|
state.dirty = true;
|
||||||
state.name = action.payload;
|
state.name = action.payload;
|
||||||
},
|
},
|
||||||
// Events
|
// 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.events.push({
|
||||||
id: shortId(),
|
id: shortId(),
|
||||||
|
type: 'event',
|
||||||
...action.payload,
|
...action.payload,
|
||||||
});
|
} as IChartEventItem);
|
||||||
},
|
},
|
||||||
duplicateEvent: (state, action: PayloadAction<Omit<IChartEvent, 'id'>>) => {
|
addFormula: (
|
||||||
|
state,
|
||||||
|
action: PayloadAction<Omit<IChartFormula, 'id'>>,
|
||||||
|
) => {
|
||||||
state.dirty = true;
|
state.dirty = true;
|
||||||
state.events.push({
|
state.events.push({
|
||||||
...action.payload,
|
|
||||||
filters: action.payload.filters.map((filter) => ({
|
|
||||||
...filter,
|
|
||||||
id: shortId(),
|
|
||||||
})),
|
|
||||||
id: shortId(),
|
id: shortId(),
|
||||||
});
|
...action.payload,
|
||||||
|
} as IChartEventItem);
|
||||||
|
},
|
||||||
|
duplicateEvent: (
|
||||||
|
state,
|
||||||
|
action: PayloadAction<IChartEventItem>,
|
||||||
|
) => {
|
||||||
|
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: (
|
removeEvent: (
|
||||||
state,
|
state,
|
||||||
@@ -113,13 +136,18 @@ export const reportSlice = createSlice({
|
|||||||
) => {
|
) => {
|
||||||
state.dirty = true;
|
state.dirty = true;
|
||||||
state.events = state.events.filter(
|
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<IChartEvent>) => {
|
changeEvent: (state, action: PayloadAction<IChartEventItem>) => {
|
||||||
state.dirty = true;
|
state.dirty = true;
|
||||||
state.events = state.events.map((event) => {
|
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 action.payload;
|
||||||
}
|
}
|
||||||
return event;
|
return event;
|
||||||
@@ -280,6 +308,7 @@ export const {
|
|||||||
setReport,
|
setReport,
|
||||||
setName,
|
setName,
|
||||||
addEvent,
|
addEvent,
|
||||||
|
addFormula,
|
||||||
removeEvent,
|
removeEvent,
|
||||||
duplicateEvent,
|
duplicateEvent,
|
||||||
changeEvent,
|
changeEvent,
|
||||||
|
|||||||
@@ -23,16 +23,19 @@ import {
|
|||||||
import { CSS } from '@dnd-kit/utilities';
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
import { shortId } from '@openpanel/common';
|
import { shortId } from '@openpanel/common';
|
||||||
import { alphabetIds } from '@openpanel/constants';
|
import { alphabetIds } from '@openpanel/constants';
|
||||||
import type { IChartEvent } from '@openpanel/validation';
|
import type { IChartEvent, IChartEventItem, IChartFormula } from '@openpanel/validation';
|
||||||
import { FilterIcon, HandIcon } from 'lucide-react';
|
import { FilterIcon, HandIcon, PlusIcon } from 'lucide-react';
|
||||||
import { ReportSegment } from '../ReportSegment';
|
import { ReportSegment } from '../ReportSegment';
|
||||||
import {
|
import {
|
||||||
addEvent,
|
addEvent,
|
||||||
|
addFormula,
|
||||||
changeEvent,
|
changeEvent,
|
||||||
duplicateEvent,
|
duplicateEvent,
|
||||||
removeEvent,
|
removeEvent,
|
||||||
reorderEvents,
|
reorderEvents,
|
||||||
} from '../reportSlice';
|
} from '../reportSlice';
|
||||||
|
import { InputEnter } from '@/components/ui/input-enter';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
import { EventPropertiesCombobox } from './EventPropertiesCombobox';
|
import { EventPropertiesCombobox } from './EventPropertiesCombobox';
|
||||||
import { PropertiesCombobox } from './PropertiesCombobox';
|
import { PropertiesCombobox } from './PropertiesCombobox';
|
||||||
import type { ReportEventMoreProps } from './ReportEventMore';
|
import type { ReportEventMoreProps } from './ReportEventMore';
|
||||||
@@ -47,21 +50,29 @@ function SortableEvent({
|
|||||||
isSelectManyEvents,
|
isSelectManyEvents,
|
||||||
...props
|
...props
|
||||||
}: {
|
}: {
|
||||||
event: IChartEvent;
|
event: IChartEventItem | IChartEvent;
|
||||||
index: number;
|
index: number;
|
||||||
showSegment: boolean;
|
showSegment: boolean;
|
||||||
showAddFilter: boolean;
|
showAddFilter: boolean;
|
||||||
isSelectManyEvents: boolean;
|
isSelectManyEvents: boolean;
|
||||||
} & React.HTMLAttributes<HTMLDivElement>) {
|
} & React.HTMLAttributes<HTMLDivElement>) {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
|
const eventId = 'type' in event ? event.id : (event as IChartEvent).id;
|
||||||
const { attributes, listeners, setNodeRef, transform, transition } =
|
const { attributes, listeners, setNodeRef, transform, transition } =
|
||||||
useSortable({ id: event.id ?? '' });
|
useSortable({ id: eventId ?? '' });
|
||||||
|
|
||||||
const style = {
|
const style = {
|
||||||
transform: CSS.Transform.toString(transform),
|
transform: CSS.Transform.toString(transform),
|
||||||
transition,
|
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 (
|
return (
|
||||||
<div ref={setNodeRef} style={style} {...attributes} {...props}>
|
<div ref={setNodeRef} style={style} {...attributes} {...props}>
|
||||||
<div className="flex items-center gap-2 p-2 group">
|
<div className="flex items-center gap-2 p-2 group">
|
||||||
@@ -76,16 +87,16 @@ function SortableEvent({
|
|||||||
{props.children}
|
{props.children}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Segment and Filter buttons */}
|
{/* Segment and Filter buttons - only for events */}
|
||||||
{(showSegment || showAddFilter) && (
|
{chartEvent && (showSegment || showAddFilter) && (
|
||||||
<div className="flex gap-2 p-2 pt-0">
|
<div className="flex gap-2 p-2 pt-0">
|
||||||
{showSegment && (
|
{showSegment && (
|
||||||
<ReportSegment
|
<ReportSegment
|
||||||
value={event.segment}
|
value={chartEvent.segment}
|
||||||
onChange={(segment) => {
|
onChange={(segment) => {
|
||||||
dispatch(
|
dispatch(
|
||||||
changeEvent({
|
changeEvent({
|
||||||
...event,
|
...chartEvent,
|
||||||
segment,
|
segment,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -94,13 +105,13 @@ function SortableEvent({
|
|||||||
)}
|
)}
|
||||||
{showAddFilter && (
|
{showAddFilter && (
|
||||||
<PropertiesCombobox
|
<PropertiesCombobox
|
||||||
event={event}
|
event={chartEvent}
|
||||||
onSelect={(action) => {
|
onSelect={(action) => {
|
||||||
dispatch(
|
dispatch(
|
||||||
changeEvent({
|
changeEvent({
|
||||||
...event,
|
...chartEvent,
|
||||||
filters: [
|
filters: [
|
||||||
...event.filters,
|
...chartEvent.filters,
|
||||||
{
|
{
|
||||||
id: shortId(),
|
id: shortId(),
|
||||||
name: action.value,
|
name: action.value,
|
||||||
@@ -124,14 +135,14 @@ function SortableEvent({
|
|||||||
</PropertiesCombobox>
|
</PropertiesCombobox>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showSegment && event.segment.startsWith('property_') && (
|
{showSegment && chartEvent.segment.startsWith('property_') && (
|
||||||
<EventPropertiesCombobox event={event} />
|
<EventPropertiesCombobox event={chartEvent} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters - only for events */}
|
||||||
{!isSelectManyEvents && <FiltersList event={event} />}
|
{chartEvent && !isSelectManyEvents && <FiltersList event={chartEvent} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -174,14 +185,15 @@ export function ReportEvents() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMore = (event: IChartEvent) => {
|
const handleMore = (event: IChartEventItem | IChartEvent) => {
|
||||||
const callback: ReportEventMoreProps['onClick'] = (action) => {
|
const callback: ReportEventMoreProps['onClick'] = (action) => {
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case 'remove': {
|
case 'remove': {
|
||||||
return dispatch(removeEvent(event));
|
return dispatch(removeEvent({ id: 'type' in event ? event.id : (event as IChartEvent).id }));
|
||||||
}
|
}
|
||||||
case 'duplicate': {
|
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;
|
return callback;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const dispatchChangeFormula = useDebounceFn((formula: IChartFormula) => {
|
||||||
|
dispatch(changeEvent(formula));
|
||||||
|
});
|
||||||
|
|
||||||
|
const showFormula = chartType !== 'conversion' && chartType !== 'funnel' && chartType !== 'retention';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="mb-2 font-medium">Events</h3>
|
<h3 className="mb-2 font-medium">Metrics</h3>
|
||||||
<DndContext
|
<DndContext
|
||||||
sensors={sensors}
|
sensors={sensors}
|
||||||
collisionDetection={closestCenter}
|
collisionDetection={closestCenter}
|
||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
>
|
>
|
||||||
<SortableContext
|
<SortableContext
|
||||||
items={selectedEvents.map((e) => ({ id: e.id ?? '' }))}
|
items={selectedEvents.map((e) => ({ id: ('type' in e ? e.id : (e as IChartEvent).id) ?? '' }))}
|
||||||
strategy={verticalListSortingStrategy}
|
strategy={verticalListSortingStrategy}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{selectedEvents.map((event, index) => {
|
{selectedEvents.map((event, index) => {
|
||||||
|
// Normalize event to have type field
|
||||||
|
const normalized: IChartEventItem =
|
||||||
|
'type' in event ? event : { ...event, type: 'event' as const };
|
||||||
|
const isFormula = normalized.type === 'formula';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SortableEvent
|
<SortableEvent
|
||||||
key={event.id}
|
key={normalized.id}
|
||||||
event={event}
|
event={normalized}
|
||||||
index={index}
|
index={index}
|
||||||
showSegment={showSegment}
|
showSegment={showSegment}
|
||||||
showAddFilter={showAddFilter}
|
showAddFilter={showAddFilter}
|
||||||
isSelectManyEvents={isSelectManyEvents}
|
isSelectManyEvents={isSelectManyEvents}
|
||||||
className="rounded-lg border bg-def-100"
|
className="rounded-lg border bg-def-100"
|
||||||
>
|
>
|
||||||
<ComboboxEvents
|
{isFormula ? (
|
||||||
className="flex-1"
|
<>
|
||||||
searchable
|
<div className="flex-1 flex flex-col gap-2">
|
||||||
multiple={isSelectManyEvents as false}
|
<InputEnter
|
||||||
value={
|
placeholder="eg: A+B, A/B"
|
||||||
(isSelectManyEvents
|
value={normalized.formula}
|
||||||
? (event.filters[0]?.value ?? [])
|
onChangeValue={(value) => {
|
||||||
: event.name) as any
|
dispatchChangeFormula({
|
||||||
}
|
...normalized,
|
||||||
onChange={(value) => {
|
formula: value,
|
||||||
dispatch(
|
});
|
||||||
changeEvent(
|
}}
|
||||||
Array.isArray(value)
|
/>
|
||||||
? {
|
{showDisplayNameInput && (
|
||||||
id: event.id,
|
<Input
|
||||||
segment: 'user',
|
placeholder={`Formula (${alphabetIds[index]})`}
|
||||||
filters: [
|
defaultValue={normalized.displayName}
|
||||||
{
|
onChange={(e) => {
|
||||||
name: 'name',
|
dispatchChangeFormula({
|
||||||
operator: 'is',
|
...normalized,
|
||||||
value: value,
|
displayName: e.target.value,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ReportEventMore onClick={handleMore(normalized)} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ComboboxEvents
|
||||||
|
className="flex-1"
|
||||||
|
searchable
|
||||||
|
multiple={isSelectManyEvents as false}
|
||||||
|
value={
|
||||||
|
(isSelectManyEvents
|
||||||
|
? ((normalized as IChartEventItem & { type: 'event' }).filters[0]?.value ?? [])
|
||||||
|
: (normalized as IChartEventItem & { type: 'event' }).name) as any
|
||||||
|
}
|
||||||
|
onChange={(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: '*',
|
);
|
||||||
}
|
}}
|
||||||
: {
|
items={eventNames}
|
||||||
...event,
|
placeholder="Select event"
|
||||||
name: value,
|
/>
|
||||||
filters: [],
|
{showDisplayNameInput && (
|
||||||
},
|
<Input
|
||||||
),
|
placeholder={
|
||||||
);
|
(normalized as IChartEventItem & { type: 'event' }).name
|
||||||
}}
|
? `${(normalized as IChartEventItem & { type: 'event' }).name} (${alphabetIds[index]})`
|
||||||
items={eventNames}
|
: 'Display name'
|
||||||
placeholder="Select event"
|
}
|
||||||
/>
|
defaultValue={(normalized as IChartEventItem & { type: 'event' }).displayName}
|
||||||
{showDisplayNameInput && (
|
onChange={(e) => {
|
||||||
<Input
|
dispatchChangeEvent({
|
||||||
placeholder={
|
...(normalized as IChartEventItem & { type: 'event' }),
|
||||||
event.name
|
displayName: e.target.value,
|
||||||
? `${event.name} (${alphabetIds[index]})`
|
});
|
||||||
: 'Display name'
|
}}
|
||||||
}
|
/>
|
||||||
defaultValue={event.displayName}
|
)}
|
||||||
onChange={(e) => {
|
<ReportEventMore onClick={handleMore(normalized)} />
|
||||||
dispatchChangeEvent({
|
</>
|
||||||
...event,
|
|
||||||
displayName: e.target.value,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
<ReportEventMore onClick={handleMore(event)} />
|
|
||||||
</SortableEvent>
|
</SortableEvent>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
<ComboboxEvents
|
<div className="flex gap-2">
|
||||||
disabled={isAddEventDisabled}
|
<ComboboxEvents
|
||||||
value={''}
|
disabled={isAddEventDisabled}
|
||||||
searchable
|
value={''}
|
||||||
onChange={(value) => {
|
searchable
|
||||||
if (isSelectManyEvents) {
|
onChange={(value) => {
|
||||||
dispatch(
|
if (isSelectManyEvents) {
|
||||||
addEvent({
|
dispatch(
|
||||||
segment: 'user',
|
addEvent({
|
||||||
name: value,
|
segment: 'user',
|
||||||
filters: [
|
name: value,
|
||||||
{
|
filters: [
|
||||||
name: 'name',
|
{
|
||||||
operator: 'is',
|
name: 'name',
|
||||||
value: [value],
|
operator: 'is',
|
||||||
},
|
value: [value],
|
||||||
],
|
},
|
||||||
}),
|
],
|
||||||
);
|
}),
|
||||||
} else {
|
);
|
||||||
dispatch(
|
} else {
|
||||||
addEvent({
|
dispatch(
|
||||||
name: value,
|
addEvent({
|
||||||
segment: 'event',
|
name: value,
|
||||||
filters: [],
|
segment: 'event',
|
||||||
}),
|
filters: [],
|
||||||
);
|
}),
|
||||||
}
|
);
|
||||||
}}
|
}
|
||||||
placeholder="Select event"
|
}}
|
||||||
items={eventNames}
|
placeholder="Select event"
|
||||||
/>
|
items={eventNames}
|
||||||
|
/>
|
||||||
|
{showFormula && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
icon={PlusIcon}
|
||||||
|
onClick={() => {
|
||||||
|
dispatch(
|
||||||
|
addFormula({
|
||||||
|
type: 'formula',
|
||||||
|
formula: '',
|
||||||
|
displayName: '',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add Formula
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SortableContext>
|
</SortableContext>
|
||||||
</DndContext>
|
</DndContext>
|
||||||
|
|||||||
@@ -9,17 +9,12 @@ import { ReportSettings } from './ReportSettings';
|
|||||||
|
|
||||||
export function ReportSidebar() {
|
export function ReportSidebar() {
|
||||||
const { chartType } = useSelector((state) => state.report);
|
const { chartType } = useSelector((state) => state.report);
|
||||||
const showFormula =
|
|
||||||
chartType !== 'conversion' &&
|
|
||||||
chartType !== 'funnel' &&
|
|
||||||
chartType !== 'retention';
|
|
||||||
const showBreakdown = chartType !== 'retention';
|
const showBreakdown = chartType !== 'retention';
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col gap-8">
|
<div className="flex flex-col gap-8">
|
||||||
<ReportEvents />
|
<ReportEvents />
|
||||||
{showBreakdown && <ReportBreakdowns />}
|
{showBreakdown && <ReportBreakdowns />}
|
||||||
{showFormula && <ReportFormula />}
|
|
||||||
<ReportSettings />
|
<ReportSettings />
|
||||||
</div>
|
</div>
|
||||||
<SheetFooter>
|
<SheetFooter>
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import RequestPasswordReset from './request-reset-password';
|
|||||||
import SaveReport from './save-report';
|
import SaveReport from './save-report';
|
||||||
import SelectBillingPlan from './select-billing-plan';
|
import SelectBillingPlan from './select-billing-plan';
|
||||||
import ShareOverviewModal from './share-overview-modal';
|
import ShareOverviewModal from './share-overview-modal';
|
||||||
|
import ViewChartUsers from './view-chart-users';
|
||||||
|
|
||||||
const modals = {
|
const modals = {
|
||||||
OverviewTopPagesModal: OverviewTopPagesModal,
|
OverviewTopPagesModal: OverviewTopPagesModal,
|
||||||
@@ -51,6 +52,7 @@ const modals = {
|
|||||||
EditReference: EditReference,
|
EditReference: EditReference,
|
||||||
ShareOverviewModal: ShareOverviewModal,
|
ShareOverviewModal: ShareOverviewModal,
|
||||||
AddReference: AddReference,
|
AddReference: AddReference,
|
||||||
|
ViewChartUsers: ViewChartUsers,
|
||||||
Instructions: Instructions,
|
Instructions: Instructions,
|
||||||
OnboardingTroubleshoot: OnboardingTroubleshoot,
|
OnboardingTroubleshoot: OnboardingTroubleshoot,
|
||||||
DateRangerPicker: DateRangerPicker,
|
DateRangerPicker: DateRangerPicker,
|
||||||
|
|||||||
112
apps/start/src/modals/view-chart-users.tsx
Normal file
112
apps/start/src/modals/view-chart-users.tsx
Normal file
@@ -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<string | number | boolean | null>;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<ModalContent>
|
||||||
|
<ModalHeader
|
||||||
|
title="View Users"
|
||||||
|
description={`Users who triggered this event on ${new Date(date).toLocaleDateString()}`}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{query.isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<div className="text-muted-foreground">Loading users...</div>
|
||||||
|
</div>
|
||||||
|
) : profiles.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<div className="text-muted-foreground">No users found</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="max-h-[60vh] overflow-y-auto">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{profiles.map((profile) => (
|
||||||
|
<div
|
||||||
|
key={profile.id}
|
||||||
|
className="flex items-center gap-3 rounded-lg border p-3"
|
||||||
|
>
|
||||||
|
{profile.avatar ? (
|
||||||
|
<img
|
||||||
|
src={profile.avatar}
|
||||||
|
alt={profile.firstName || profile.email}
|
||||||
|
className="size-10 rounded-full"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex size-10 items-center justify-center rounded-full bg-muted">
|
||||||
|
<UsersIcon size={20} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="font-medium">
|
||||||
|
{profile.firstName || profile.lastName
|
||||||
|
? `${profile.firstName || ''} ${profile.lastName || ''}`.trim()
|
||||||
|
: profile.email || 'Anonymous'}
|
||||||
|
</div>
|
||||||
|
{profile.email && (
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{profile.email}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<ButtonContainer>
|
||||||
|
<Button type="button" variant="outline" onClick={() => popModal()}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</ButtonContainer>
|
||||||
|
</div>
|
||||||
|
</ModalContent>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
112
packages/db/scripts/ch-copy-from-remote.ts
Normal file
112
packages/db/scripts/ch-copy-from-remote.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { stdin as input, stdout as output } from 'node:process';
|
||||||
|
import { createInterface } from 'node:readline/promises';
|
||||||
|
import { parseArgs } from 'node:util';
|
||||||
|
import sqlstring from 'sqlstring';
|
||||||
|
import { ch } from '../src/clickhouse/client';
|
||||||
|
import { clix } from '../src/clickhouse/query-builder';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const rl = createInterface({ input, output });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { values } = parseArgs({
|
||||||
|
args: process.argv.slice(2),
|
||||||
|
options: {
|
||||||
|
host: { type: 'string' },
|
||||||
|
user: { type: 'string' },
|
||||||
|
password: { type: 'string' },
|
||||||
|
db: { type: 'string' },
|
||||||
|
start: { type: 'string' },
|
||||||
|
end: { type: 'string' },
|
||||||
|
projects: { type: 'string' },
|
||||||
|
},
|
||||||
|
strict: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const getArg = (val: unknown): string | undefined =>
|
||||||
|
typeof val === 'string' ? val : undefined;
|
||||||
|
|
||||||
|
console.log('Copy data from remote ClickHouse to local');
|
||||||
|
console.log('---------------------------------------');
|
||||||
|
|
||||||
|
const host =
|
||||||
|
getArg(values.host) || (await rl.question('Remote Host (IP/Domain): '));
|
||||||
|
if (!host) throw new Error('Host is required');
|
||||||
|
|
||||||
|
const user = getArg(values.user) || (await rl.question('Remote User: '));
|
||||||
|
if (!user) throw new Error('User is required');
|
||||||
|
|
||||||
|
const password =
|
||||||
|
getArg(values.password) || (await rl.question('Remote Password: '));
|
||||||
|
if (!password) throw new Error('Password is required');
|
||||||
|
|
||||||
|
const dbName =
|
||||||
|
getArg(values.db) ||
|
||||||
|
(await rl.question('Remote DB Name (default: openpanel): ')) ||
|
||||||
|
'openpanel';
|
||||||
|
|
||||||
|
const startDate =
|
||||||
|
getArg(values.start) ||
|
||||||
|
(await rl.question('Start Date (YYYY-MM-DD HH:mm:ss): '));
|
||||||
|
if (!startDate) throw new Error('Start date is required');
|
||||||
|
|
||||||
|
const endDate =
|
||||||
|
getArg(values.end) ||
|
||||||
|
(await rl.question('End Date (YYYY-MM-DD HH:mm:ss): '));
|
||||||
|
if (!endDate) throw new Error('End date is required');
|
||||||
|
|
||||||
|
const projectIdsInput =
|
||||||
|
getArg(values.projects) ||
|
||||||
|
(await rl.question(
|
||||||
|
'Project IDs (comma separated, leave empty for all): ',
|
||||||
|
));
|
||||||
|
const projectIds = projectIdsInput
|
||||||
|
? projectIdsInput.split(',').map((s: string) => s.trim())
|
||||||
|
: [];
|
||||||
|
|
||||||
|
console.log('\nStarting copy process...');
|
||||||
|
|
||||||
|
const tables = ['sessions', 'events'];
|
||||||
|
|
||||||
|
for (const table of tables) {
|
||||||
|
console.log(`Processing table: ${table}`);
|
||||||
|
|
||||||
|
// Build the SELECT part using the query builder
|
||||||
|
// We use sqlstring to escape the remote function arguments
|
||||||
|
const remoteTable = `remote(${sqlstring.escape(host)}, ${sqlstring.escape(dbName)}, ${sqlstring.escape(table)}, ${sqlstring.escape(user)}, ${sqlstring.escape(password)})`;
|
||||||
|
|
||||||
|
const queryBuilder = clix(ch)
|
||||||
|
.from(remoteTable)
|
||||||
|
.select(['*'])
|
||||||
|
.where('created_at', 'BETWEEN', [startDate, endDate]);
|
||||||
|
|
||||||
|
if (projectIds.length > 0) {
|
||||||
|
queryBuilder.where('project_id', 'IN', projectIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectQuery = queryBuilder.toSQL();
|
||||||
|
const insertQuery = `INSERT INTO ${dbName}.${table} ${selectQuery}`;
|
||||||
|
|
||||||
|
console.log(`Executing: ${insertQuery}`);
|
||||||
|
|
||||||
|
// try {
|
||||||
|
// await ch.command({
|
||||||
|
// query: insertQuery,
|
||||||
|
// });
|
||||||
|
// console.log(`✅ Copied ${table} successfully`);
|
||||||
|
// } catch (error) {
|
||||||
|
// console.error(`❌ Failed to copy ${table}:`, error);
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\nDone!');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('\nError:', error);
|
||||||
|
} finally {
|
||||||
|
rl.close();
|
||||||
|
await ch.close();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
96
packages/db/scripts/ch-update-sessions-with-revenue.ts
Normal file
96
packages/db/scripts/ch-update-sessions-with-revenue.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { TABLE_NAMES, ch } from '../src/clickhouse/client';
|
||||||
|
import { clix } from '../src/clickhouse/query-builder';
|
||||||
|
|
||||||
|
const START_DATE = new Date('2025-11-10T00:00:00Z');
|
||||||
|
const END_DATE = new Date('2025-11-20T23:00:00Z');
|
||||||
|
const SESSIONS_PER_HOUR = 2;
|
||||||
|
|
||||||
|
// Revenue between $10 (1000 cents) and $200 (20000 cents)
|
||||||
|
const MIN_REVENUE = 1000;
|
||||||
|
const MAX_REVENUE = 20000;
|
||||||
|
|
||||||
|
function getRandomRevenue() {
|
||||||
|
return (
|
||||||
|
Math.floor(Math.random() * (MAX_REVENUE - MIN_REVENUE + 1)) + MIN_REVENUE
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log(
|
||||||
|
`Starting revenue update for sessions between ${START_DATE.toISOString()} and ${END_DATE.toISOString()}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
let currentDate = new Date(START_DATE);
|
||||||
|
|
||||||
|
while (currentDate < END_DATE) {
|
||||||
|
const nextHour = new Date(currentDate.getTime() + 60 * 60 * 1000);
|
||||||
|
console.log(`Processing hour: ${currentDate.toISOString()}`);
|
||||||
|
|
||||||
|
// 1. Pick random sessions for this hour
|
||||||
|
const sessions = await clix(ch)
|
||||||
|
.from(TABLE_NAMES.sessions)
|
||||||
|
.select(['id'])
|
||||||
|
.where('created_at', '>=', currentDate)
|
||||||
|
.andWhere('created_at', '<', nextHour)
|
||||||
|
.where('project_id', '=', 'public-web')
|
||||||
|
.limit(SESSIONS_PER_HOUR)
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
if (sessions.length === 0) {
|
||||||
|
console.log(`No sessions found for ${currentDate.toISOString()}`);
|
||||||
|
currentDate = nextHour;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionIds = sessions.map((s: any) => s.id);
|
||||||
|
console.log(
|
||||||
|
`Found ${sessionIds.length} sessions to update: ${sessionIds.join(', ')}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Construct update query
|
||||||
|
// We want to assign a DIFFERENT random revenue to each session
|
||||||
|
// Query: ALTER TABLE sessions UPDATE revenue = if(id='id1', rev1, if(id='id2', rev2, ...)) WHERE id IN ('id1', 'id2', ...)
|
||||||
|
|
||||||
|
const updates: { id: string; revenue: number }[] = [];
|
||||||
|
|
||||||
|
for (const id of sessionIds) {
|
||||||
|
const revenue = getRandomRevenue();
|
||||||
|
updates.push({ id, revenue });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build nested if() for the update expression
|
||||||
|
// ClickHouse doesn't have CASE WHEN in UPDATE expression in the same way, but if() works.
|
||||||
|
// Actually multiIf is cleaner: multiIf(id='id1', rev1, id='id2', rev2, revenue)
|
||||||
|
|
||||||
|
const conditions = updates
|
||||||
|
.map((u) => `id = '${u.id}', ${u.revenue}`)
|
||||||
|
.join(', ');
|
||||||
|
const updateExpr = `multiIf(${conditions}, revenue)`;
|
||||||
|
|
||||||
|
const idsStr = sessionIds.map((id: string) => `'${id}'`).join(', ');
|
||||||
|
const query = `ALTER TABLE ${TABLE_NAMES.sessions} UPDATE revenue = ${updateExpr} WHERE id IN (${idsStr})`;
|
||||||
|
|
||||||
|
console.log(`Executing update: ${query}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ch.command({
|
||||||
|
query,
|
||||||
|
});
|
||||||
|
console.log('Update command sent.');
|
||||||
|
|
||||||
|
// Wait a bit to not overload mutations if running on a large range
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update sessions:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
currentDate = nextHour;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Done!');
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error('Script failed:', error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -175,8 +175,8 @@ export function getChartSql({
|
|||||||
|
|
||||||
if (event.segment === 'property_sum' && event.property) {
|
if (event.segment === 'property_sum' && event.property) {
|
||||||
if (event.property === 'revenue') {
|
if (event.property === 'revenue') {
|
||||||
sb.select.count = `sum(revenue) as count`;
|
sb.select.count = 'sum(revenue) as count';
|
||||||
sb.where.property = `revenue > 0`;
|
sb.where.property = 'revenue > 0';
|
||||||
} else {
|
} else {
|
||||||
sb.select.count = `sum(toFloat64(${getSelectPropertyKey(event.property)})) as count`;
|
sb.select.count = `sum(toFloat64(${getSelectPropertyKey(event.property)})) as count`;
|
||||||
sb.where.property = `${getSelectPropertyKey(event.property)} IS NOT NULL AND notEmpty(${getSelectPropertyKey(event.property)})`;
|
sb.where.property = `${getSelectPropertyKey(event.property)} IS NOT NULL AND notEmpty(${getSelectPropertyKey(event.property)})`;
|
||||||
@@ -185,8 +185,8 @@ export function getChartSql({
|
|||||||
|
|
||||||
if (event.segment === 'property_average' && event.property) {
|
if (event.segment === 'property_average' && event.property) {
|
||||||
if (event.property === 'revenue') {
|
if (event.property === 'revenue') {
|
||||||
sb.select.count = `avg(revenue) as count`;
|
sb.select.count = 'avg(revenue) as count';
|
||||||
sb.where.property = `revenue > 0`;
|
sb.where.property = 'revenue > 0';
|
||||||
} else {
|
} else {
|
||||||
sb.select.count = `avg(toFloat64(${getSelectPropertyKey(event.property)})) as count`;
|
sb.select.count = `avg(toFloat64(${getSelectPropertyKey(event.property)})) as count`;
|
||||||
sb.where.property = `${getSelectPropertyKey(event.property)} IS NOT NULL AND notEmpty(${getSelectPropertyKey(event.property)})`;
|
sb.where.property = `${getSelectPropertyKey(event.property)} IS NOT NULL AND notEmpty(${getSelectPropertyKey(event.property)})`;
|
||||||
@@ -195,8 +195,8 @@ export function getChartSql({
|
|||||||
|
|
||||||
if (event.segment === 'property_max' && event.property) {
|
if (event.segment === 'property_max' && event.property) {
|
||||||
if (event.property === 'revenue') {
|
if (event.property === 'revenue') {
|
||||||
sb.select.count = `max(revenue) as count`;
|
sb.select.count = 'max(revenue) as count';
|
||||||
sb.where.property = `revenue > 0`;
|
sb.where.property = 'revenue > 0';
|
||||||
} else {
|
} else {
|
||||||
sb.select.count = `max(toFloat64(${getSelectPropertyKey(event.property)})) as count`;
|
sb.select.count = `max(toFloat64(${getSelectPropertyKey(event.property)})) as count`;
|
||||||
sb.where.property = `${getSelectPropertyKey(event.property)} IS NOT NULL AND notEmpty(${getSelectPropertyKey(event.property)})`;
|
sb.where.property = `${getSelectPropertyKey(event.property)} IS NOT NULL AND notEmpty(${getSelectPropertyKey(event.property)})`;
|
||||||
@@ -205,8 +205,8 @@ export function getChartSql({
|
|||||||
|
|
||||||
if (event.segment === 'property_min' && event.property) {
|
if (event.segment === 'property_min' && event.property) {
|
||||||
if (event.property === 'revenue') {
|
if (event.property === 'revenue') {
|
||||||
sb.select.count = `min(revenue) as count`;
|
sb.select.count = 'min(revenue) as count';
|
||||||
sb.where.property = `revenue > 0`;
|
sb.where.property = 'revenue > 0';
|
||||||
} else {
|
} else {
|
||||||
sb.select.count = `min(toFloat64(${getSelectPropertyKey(event.property)})) as count`;
|
sb.select.count = `min(toFloat64(${getSelectPropertyKey(event.property)})) as count`;
|
||||||
sb.where.property = `${getSelectPropertyKey(event.property)} IS NOT NULL AND notEmpty(${getSelectPropertyKey(event.property)})`;
|
sb.where.property = `${getSelectPropertyKey(event.property)} IS NOT NULL AND notEmpty(${getSelectPropertyKey(event.property)})`;
|
||||||
@@ -230,16 +230,13 @@ export function getChartSql({
|
|||||||
return sql;
|
return sql;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add total unique count for user segment using a scalar subquery
|
const totalUniqueSubquery = `(
|
||||||
if (event.segment === 'user') {
|
|
||||||
const totalUniqueSubquery = `(
|
|
||||||
SELECT ${sb.select.count}
|
SELECT ${sb.select.count}
|
||||||
FROM ${sb.from}
|
FROM ${sb.from}
|
||||||
${getJoins()}
|
${getJoins()}
|
||||||
${getWhere()}
|
${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()}`;
|
const sql = `${getSelect()} ${getFrom()} ${getJoins()} ${getWhere()} ${getGroupBy()} ${getOrderBy()} ${getFill()}`;
|
||||||
console.log('-- Report --');
|
console.log('-- Report --');
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import type {
|
|||||||
IChartBreakdown,
|
IChartBreakdown,
|
||||||
IChartEvent,
|
IChartEvent,
|
||||||
IChartEventFilter,
|
IChartEventFilter,
|
||||||
|
IChartEventItem,
|
||||||
|
IChartFormula,
|
||||||
IChartLineType,
|
IChartLineType,
|
||||||
IChartProps,
|
IChartProps,
|
||||||
IChartRange,
|
IChartRange,
|
||||||
@@ -31,11 +33,39 @@ export function transformFilter(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function transformReportEvent(
|
export function transformReportEventItem(
|
||||||
event: Partial<IChartEvent>,
|
item: Partial<IChartEventItem> | Partial<IChartEvent>,
|
||||||
index: number,
|
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<IChartFormula>;
|
||||||
|
return {
|
||||||
|
type: 'formula',
|
||||||
|
id: formula.id ?? alphabetIds[index]!,
|
||||||
|
formula: formula.formula || '',
|
||||||
|
displayName: formula.displayName,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Transform event with type field
|
||||||
|
const event = item as Partial<IChartEvent>;
|
||||||
|
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<IChartEvent>;
|
||||||
return {
|
return {
|
||||||
|
type: 'event',
|
||||||
segment: event.segment ?? 'event',
|
segment: event.segment ?? 'event',
|
||||||
filters: (event.filters ?? []).map(transformFilter),
|
filters: (event.filters ?? []).map(transformFilter),
|
||||||
id: event.id ?? alphabetIds[index]!,
|
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<IChartEvent>,
|
||||||
|
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(
|
export function transformReport(
|
||||||
report: DbReport & { layout?: ReportLayout | null },
|
report: DbReport & { layout?: ReportLayout | null },
|
||||||
): IChartProps & { id: string; 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<IChartEventItem> | Partial<IChartEvent>
|
||||||
|
>;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: report.id,
|
id: report.id,
|
||||||
projectId: report.projectId,
|
projectId: report.projectId,
|
||||||
events: (report.events as IChartEvent[]).map(transformReportEvent),
|
events: 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,
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ import {
|
|||||||
import type {
|
import type {
|
||||||
FinalChart,
|
FinalChart,
|
||||||
IChartEvent,
|
IChartEvent,
|
||||||
|
IChartEventItem,
|
||||||
|
IChartFormula,
|
||||||
IChartInput,
|
IChartInput,
|
||||||
IChartInputWithDates,
|
IChartInputWithDates,
|
||||||
IGetChartDataInput,
|
IGetChartDataInput,
|
||||||
@@ -59,10 +61,14 @@ export function withFormula(
|
|||||||
const hasBreakdowns = series.some(
|
const hasBreakdowns = series.some(
|
||||||
(serie) =>
|
(serie) =>
|
||||||
serie.name.length > 0 &&
|
serie.name.length > 0 &&
|
||||||
!events.some(
|
!events.some((event) => {
|
||||||
(event) =>
|
if (event.type === 'event') {
|
||||||
serie.name[0] === event.name || serie.name[0] === event.displayName,
|
return (
|
||||||
),
|
serie.name[0] === event.name || serie.name[0] === event.displayName
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const seriesByBreakdown = new Map<string, typeof series>();
|
const seriesByBreakdown = new Map<string, typeof series>();
|
||||||
@@ -128,6 +134,11 @@ export function withFormula(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Find the series for this event in this breakdown group
|
// 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 eventId = event.id ?? event.name;
|
||||||
const matchingSerie = seriesByEvent.get(eventId);
|
const matchingSerie = seriesByEvent.get(eventId);
|
||||||
|
|
||||||
@@ -212,17 +223,27 @@ export async function getFunnelData({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const funnels = payload.events.map((event) => {
|
const funnels = payload.events
|
||||||
const { sb, getWhere } = createSqlBuilder();
|
.filter(
|
||||||
sb.where = getEventFiltersWhereClause(event.filters);
|
(event): event is IChartEventItem & { type: 'event' } =>
|
||||||
sb.where.name = `name = ${sqlstring.escape(event.name)}`;
|
event.type === 'event',
|
||||||
return getWhere().replace('WHERE ', '');
|
)
|
||||||
});
|
.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
|
const commonWhere = `project_id = ${sqlstring.escape(projectId)} AND
|
||||||
created_at >= '${formatClickhouseDate(startDate)}' AND
|
created_at >= '${formatClickhouseDate(startDate)}' AND
|
||||||
created_at <= '${formatClickhouseDate(endDate)}'`;
|
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
|
const innerSql = `SELECT
|
||||||
${funnelGroup[0]} AS ${funnelGroup[1]},
|
${funnelGroup[0]} AS ${funnelGroup[1]},
|
||||||
windowFunnel(${funnelWindow}, 'strict_increase')(toUnixTimestamp(created_at), ${funnels.join(', ')}) AS level
|
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`}
|
${funnelGroup[0] === 'session_id' ? '' : `LEFT JOIN (SELECT profile_id, id FROM sessions WHERE ${commonWhere}) AS s ON s.id = e.session_id`}
|
||||||
WHERE
|
WHERE
|
||||||
${commonWhere} AND
|
${commonWhere} AND
|
||||||
name IN (${payload.events.map((event) => sqlstring.escape(event.name)).join(', ')})
|
name IN (${eventNames.join(', ')})
|
||||||
GROUP BY ${funnelGroup[0]}`;
|
GROUP BY ${funnelGroup[0]}`;
|
||||||
|
|
||||||
const sql = `SELECT level, count() AS count FROM (${innerSql}) WHERE level != 0 GROUP BY level ORDER BY level DESC`;
|
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(
|
const steps = reverse(filledFunnelRes).reduce(
|
||||||
(acc, item, index, list) => {
|
(acc, item, index, list) => {
|
||||||
const prev = list[index - 1] ?? { count: totalSessions };
|
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 [
|
return [
|
||||||
...acc,
|
...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<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 type IGetChartSerie = Awaited<ReturnType<typeof getChartSeries>>[number];
|
||||||
export async function getChartSeries(
|
export async function getChartSeries(
|
||||||
input: IChartInputWithDates,
|
input: IChartInputWithDates,
|
||||||
timezone: string,
|
timezone: string,
|
||||||
) {
|
) {
|
||||||
const series = (
|
// Normalize all events to have type field
|
||||||
await Promise.all(
|
const normalizedEvents = input.events.map(normalizeEventItem);
|
||||||
input.events.map(async (event) =>
|
|
||||||
getChartSerie(
|
|
||||||
{
|
|
||||||
...input,
|
|
||||||
event,
|
|
||||||
},
|
|
||||||
timezone,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
).flat();
|
|
||||||
|
|
||||||
try {
|
// Process events sequentially - events fetch data, formulas calculate from previous series
|
||||||
return withFormula(input, series);
|
const allSeries: Awaited<ReturnType<typeof getChartSerie>> = [];
|
||||||
} catch (e) {
|
|
||||||
return series;
|
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) {
|
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) =>
|
const getSerieId = (serie: IGetChartSerie) =>
|
||||||
[slug(serie.name.join('-')), serie.event.id].filter(Boolean).join('-');
|
[slug(serie.name.join('-')), serie.event.id].filter(Boolean).join('-');
|
||||||
const result = await Promise.all(promises);
|
const result = await Promise.all(promises);
|
||||||
@@ -368,36 +616,163 @@ export async function getChart(input: IChartInput) {
|
|||||||
const previousSeries = result[1];
|
const previousSeries = result[1];
|
||||||
const limit = input.limit || 300;
|
const limit = input.limit || 300;
|
||||||
const offset = input.offset || 0;
|
const offset = input.offset || 0;
|
||||||
const includeEventAlphaId = input.events.length > 1;
|
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 = {
|
const final: FinalChart = {
|
||||||
series: series.map((serie, index) => {
|
series: seriesWithMetrics.map(({ serie, eventIndex, metrics }) => {
|
||||||
const eventIndex = input.events.findIndex(
|
|
||||||
(event) => event.id === serie.event.id,
|
|
||||||
);
|
|
||||||
const alphaId = alphabetIds[eventIndex];
|
const alphaId = alphabetIds[eventIndex];
|
||||||
const previousSerie = previousSeries?.find(
|
const previousSerie = previousSeries?.find(
|
||||||
(prevSerie) => getSerieId(prevSerie) === getSerieId(serie),
|
(prevSerie) => getSerieId(prevSerie) === getSerieId(serie),
|
||||||
);
|
);
|
||||||
const metrics = {
|
|
||||||
sum: sum(serie.data.map((item) => item.count)),
|
// Determine if this is a formula series
|
||||||
average: round(average(serie.data.map((item) => item.count)), 2),
|
const isFormula = normalizedEvents[eventIndex]?.type === 'formula';
|
||||||
min: min(serie.data.map((item) => item.count)),
|
const eventItem = normalizedEvents[eventIndex];
|
||||||
max: max(serie.data.map((item) => item.count)),
|
|
||||||
count: serie.data[0]?.total_count, // We can grab any since all are the same
|
|
||||||
};
|
|
||||||
const event = {
|
const event = {
|
||||||
id: serie.event.id,
|
id: serie.event.id,
|
||||||
name: serie.event.displayName || serie.event.name,
|
name: serie.event.displayName || serie.event.name,
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
// Construct names array based on whether it's a formula or event
|
||||||
id: getSerieId(serie),
|
let names: string[];
|
||||||
names:
|
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
|
input.breakdowns.length === 0 && serie.event.displayName
|
||||||
? [serie.event.displayName]
|
? [serie.event.displayName]
|
||||||
: includeEventAlphaId
|
: includeEventAlphaId
|
||||||
? [`(${alphaId}) ${serie.name[0]}`, ...serie.name.slice(1)]
|
? [`(${alphaId}) ${serie.name[0]}`, ...serie.name.slice(1)]
|
||||||
: serie.name,
|
: serie.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: getSerieId(serie),
|
||||||
|
names,
|
||||||
event,
|
event,
|
||||||
metrics: {
|
metrics: {
|
||||||
...metrics,
|
...metrics,
|
||||||
|
|||||||
@@ -10,15 +10,20 @@ import {
|
|||||||
chQuery,
|
chQuery,
|
||||||
clix,
|
clix,
|
||||||
conversionService,
|
conversionService,
|
||||||
|
createSqlBuilder,
|
||||||
db,
|
db,
|
||||||
|
formatClickhouseDate,
|
||||||
funnelService,
|
funnelService,
|
||||||
getChartPrevStartEndDate,
|
getChartPrevStartEndDate,
|
||||||
getChartStartEndDate,
|
getChartStartEndDate,
|
||||||
|
getEventFiltersWhereClause,
|
||||||
getEventMetasCached,
|
getEventMetasCached,
|
||||||
|
getProfilesCached,
|
||||||
getSelectPropertyKey,
|
getSelectPropertyKey,
|
||||||
getSettingsForProject,
|
getSettingsForProject,
|
||||||
} from '@openpanel/db';
|
} from '@openpanel/db';
|
||||||
import {
|
import {
|
||||||
|
zChartEvent,
|
||||||
zChartInput,
|
zChartInput,
|
||||||
zCriteria,
|
zCriteria,
|
||||||
zRange,
|
zRange,
|
||||||
@@ -532,6 +537,166 @@ export const chartRouter = createTRPCRouter({
|
|||||||
|
|
||||||
return processCohortData(cohortData, diffInterval);
|
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(
|
function processCohortData(
|
||||||
|
|||||||
@@ -57,12 +57,51 @@ export const zChartEvent = z.object({
|
|||||||
.default([])
|
.default([])
|
||||||
.describe('Filters applied specifically to this event'),
|
.describe('Filters applied specifically to this event'),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const zChartFormula = z.object({
|
||||||
|
id: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe('Unique identifier for the formula configuration'),
|
||||||
|
type: z.literal('formula'),
|
||||||
|
formula: z.string().describe('The formula expression (e.g., A+B, A/B)'),
|
||||||
|
displayName: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe('A user-friendly name for display purposes'),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Event with type field for discriminated union
|
||||||
|
export const zChartEventWithType = zChartEvent.extend({
|
||||||
|
type: z.literal('event'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const zChartEventItem = z.discriminatedUnion('type', [
|
||||||
|
zChartEventWithType,
|
||||||
|
zChartFormula,
|
||||||
|
]);
|
||||||
|
|
||||||
export const zChartBreakdown = z.object({
|
export const zChartBreakdown = z.object({
|
||||||
id: z.string().optional(),
|
id: z.string().optional(),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const zChartEvents = z.array(zChartEvent);
|
// Support both old format (array of events without type) and new format (array of event/formula items)
|
||||||
|
// Preprocess to normalize: if item has 'type' field, use discriminated union; otherwise, add type: 'event'
|
||||||
|
export const 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 zChartBreakdowns = z.array(zChartBreakdown);
|
||||||
|
|
||||||
export const zChartType = z.enum(objectToZodEnums(chartTypes));
|
export const zChartType = z.enum(objectToZodEnums(chartTypes));
|
||||||
|
|||||||
28
packages/validation/src/test.ts
Normal file
28
packages/validation/src/test.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { zChartEvents } from '.';
|
||||||
|
|
||||||
|
const events = [
|
||||||
|
{
|
||||||
|
id: 'sAmT',
|
||||||
|
type: 'event',
|
||||||
|
name: 'session_end',
|
||||||
|
segment: 'event',
|
||||||
|
filters: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '5K2v',
|
||||||
|
type: 'event',
|
||||||
|
name: 'session_start',
|
||||||
|
segment: 'event',
|
||||||
|
filters: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lQiQ',
|
||||||
|
type: 'formula',
|
||||||
|
formula: 'A/B',
|
||||||
|
displayName: '',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const res = zChartEvents.safeParse(events);
|
||||||
|
|
||||||
|
console.log(res);
|
||||||
@@ -3,7 +3,9 @@ import type { z } from 'zod';
|
|||||||
import type {
|
import type {
|
||||||
zChartBreakdown,
|
zChartBreakdown,
|
||||||
zChartEvent,
|
zChartEvent,
|
||||||
|
zChartEventItem,
|
||||||
zChartEventSegment,
|
zChartEventSegment,
|
||||||
|
zChartFormula,
|
||||||
zChartInput,
|
zChartInput,
|
||||||
zChartInputAI,
|
zChartInputAI,
|
||||||
zChartType,
|
zChartType,
|
||||||
@@ -24,6 +26,8 @@ export type IChartProps = z.infer<typeof zReportInput> & {
|
|||||||
previousIndicatorInverted?: boolean;
|
previousIndicatorInverted?: boolean;
|
||||||
};
|
};
|
||||||
export type IChartEvent = z.infer<typeof zChartEvent>;
|
export type IChartEvent = z.infer<typeof zChartEvent>;
|
||||||
|
export type IChartFormula = z.infer<typeof zChartFormula>;
|
||||||
|
export type IChartEventItem = z.infer<typeof zChartEventItem>;
|
||||||
export type 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 =
|
||||||
|
|||||||
Reference in New Issue
Block a user