ts
This commit is contained in:
@@ -13,11 +13,7 @@ import {
|
|||||||
getSettingsForProject,
|
getSettingsForProject,
|
||||||
} from '@openpanel/db';
|
} from '@openpanel/db';
|
||||||
import { ChartEngine } from '@openpanel/db';
|
import { ChartEngine } from '@openpanel/db';
|
||||||
import {
|
import { zChartEvent, zChartInputBase } from '@openpanel/validation';
|
||||||
zChartEvent,
|
|
||||||
zChartInput,
|
|
||||||
zChartInputBase,
|
|
||||||
} from '@openpanel/validation';
|
|
||||||
import { omit } from 'ramda';
|
import { omit } from 'ramda';
|
||||||
|
|
||||||
async function getProjectId(
|
async function getProjectId(
|
||||||
|
|||||||
@@ -1,12 +1,4 @@
|
|||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow as UITableRow,
|
|
||||||
} from '@/components/ui/table';
|
|
||||||
import { useFormatDateInterval } from '@/hooks/use-format-date-interval';
|
import { useFormatDateInterval } from '@/hooks/use-format-date-interval';
|
||||||
import { useNumber } from '@/hooks/use-numer-formatter';
|
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||||
import { useSelector } from '@/redux';
|
import { useSelector } from '@/redux';
|
||||||
@@ -28,8 +20,8 @@ import {
|
|||||||
} from '@tanstack/react-virtual';
|
} from '@tanstack/react-virtual';
|
||||||
import throttle from 'lodash.throttle';
|
import throttle from 'lodash.throttle';
|
||||||
import { ChevronDown, ChevronRight } from 'lucide-react';
|
import { ChevronDown, ChevronRight } from 'lucide-react';
|
||||||
import { memo, useEffect, useMemo, useRef, useState } from 'react';
|
|
||||||
import type * as React from 'react';
|
import type * as React from 'react';
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { ReportTableToolbar } from './report-table-toolbar';
|
import { ReportTableToolbar } from './report-table-toolbar';
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -30,16 +30,7 @@ interface Props {
|
|||||||
|
|
||||||
export function Chart({ data }: Props) {
|
export function Chart({ data }: Props) {
|
||||||
const {
|
const {
|
||||||
report: {
|
report: { interval, projectId, startDate, endDate, range, lineType },
|
||||||
previous,
|
|
||||||
interval,
|
|
||||||
projectId,
|
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
range,
|
|
||||||
lineType,
|
|
||||||
events,
|
|
||||||
},
|
|
||||||
isEditMode,
|
isEditMode,
|
||||||
options: { hideXAxis, hideYAxis, maxDomain },
|
options: { hideXAxis, hideYAxis, maxDomain },
|
||||||
} = useReportChartContext();
|
} = useReportChartContext();
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ type ChartRootShortcutProps = Omit<ReportChartProps, 'report'> & {
|
|||||||
previous?: ReportChartProps['report']['previous'];
|
previous?: ReportChartProps['report']['previous'];
|
||||||
chartType?: ReportChartProps['report']['chartType'];
|
chartType?: ReportChartProps['report']['chartType'];
|
||||||
interval?: ReportChartProps['report']['interval'];
|
interval?: ReportChartProps['report']['interval'];
|
||||||
events: ReportChartProps['report']['events'];
|
series: ReportChartProps['report']['series'];
|
||||||
breakdowns?: ReportChartProps['report']['breakdowns'];
|
breakdowns?: ReportChartProps['report']['breakdowns'];
|
||||||
lineType?: ReportChartProps['report']['lineType'];
|
lineType?: ReportChartProps['report']['lineType'];
|
||||||
};
|
};
|
||||||
@@ -18,7 +18,7 @@ export const ReportChartShortcut = ({
|
|||||||
previous = false,
|
previous = false,
|
||||||
chartType = 'linear',
|
chartType = 'linear',
|
||||||
interval = 'day',
|
interval = 'day',
|
||||||
events,
|
series,
|
||||||
breakdowns,
|
breakdowns,
|
||||||
lineType = 'monotone',
|
lineType = 'monotone',
|
||||||
options,
|
options,
|
||||||
@@ -33,7 +33,7 @@ export const ReportChartShortcut = ({
|
|||||||
previous,
|
previous,
|
||||||
chartType,
|
chartType,
|
||||||
interval,
|
interval,
|
||||||
events,
|
series,
|
||||||
lineType,
|
lineType,
|
||||||
metric: 'sum',
|
metric: 'sum',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
} from '@openpanel/constants';
|
} from '@openpanel/constants';
|
||||||
import type {
|
import type {
|
||||||
IChartBreakdown,
|
IChartBreakdown,
|
||||||
IChartEvent,
|
|
||||||
IChartEventItem,
|
IChartEventItem,
|
||||||
IChartFormula,
|
IChartFormula,
|
||||||
IChartLineType,
|
IChartLineType,
|
||||||
@@ -19,6 +18,7 @@ import type {
|
|||||||
IChartRange,
|
IChartRange,
|
||||||
IChartType,
|
IChartType,
|
||||||
IInterval,
|
IInterval,
|
||||||
|
UnionOmit,
|
||||||
zCriteria,
|
zCriteria,
|
||||||
} from '@openpanel/validation';
|
} from '@openpanel/validation';
|
||||||
import type { z } from 'zod';
|
import type { z } from 'zod';
|
||||||
@@ -89,37 +89,26 @@ export const reportSlice = createSlice({
|
|||||||
state.name = action.payload;
|
state.name = action.payload;
|
||||||
},
|
},
|
||||||
// Series (Events and Formulas)
|
// Series (Events and Formulas)
|
||||||
addEvent: (state, action: PayloadAction<Omit<IChartEvent, 'id'>>) => {
|
addSerie: (
|
||||||
state.dirty = true;
|
|
||||||
state.series.push({
|
|
||||||
id: shortId(),
|
|
||||||
type: 'event',
|
|
||||||
...action.payload,
|
|
||||||
} as IChartEventItem);
|
|
||||||
},
|
|
||||||
addFormula: (
|
|
||||||
state,
|
state,
|
||||||
action: PayloadAction<Omit<IChartFormula, 'id'>>,
|
action: PayloadAction<UnionOmit<IChartEventItem, 'id'>>,
|
||||||
) => {
|
) => {
|
||||||
state.dirty = true;
|
state.dirty = true;
|
||||||
state.series.push({
|
state.series.push({
|
||||||
id: shortId(),
|
id: shortId(),
|
||||||
...action.payload,
|
...action.payload,
|
||||||
} as IChartEventItem);
|
});
|
||||||
},
|
},
|
||||||
duplicateEvent: (
|
duplicateEvent: (state, action: PayloadAction<IChartEventItem>) => {
|
||||||
state,
|
|
||||||
action: PayloadAction<IChartEventItem>,
|
|
||||||
) => {
|
|
||||||
state.dirty = true;
|
state.dirty = true;
|
||||||
if (action.payload.type === 'event') {
|
if (action.payload.type === 'event') {
|
||||||
state.series.push({
|
state.series.push({
|
||||||
...action.payload,
|
...action.payload,
|
||||||
filters: action.payload.filters.map((filter) => ({
|
filters: action.payload.filters.map((filter) => ({
|
||||||
...filter,
|
...filter,
|
||||||
|
id: shortId(),
|
||||||
|
})),
|
||||||
id: shortId(),
|
id: shortId(),
|
||||||
})),
|
|
||||||
id: shortId(),
|
|
||||||
} as IChartEventItem);
|
} as IChartEventItem);
|
||||||
} else {
|
} else {
|
||||||
state.series.push({
|
state.series.push({
|
||||||
@@ -135,19 +124,14 @@ export const reportSlice = createSlice({
|
|||||||
}>,
|
}>,
|
||||||
) => {
|
) => {
|
||||||
state.dirty = true;
|
state.dirty = true;
|
||||||
state.series = state.series.filter(
|
state.series = state.series.filter((event) => {
|
||||||
(event) => {
|
return event.id !== action.payload.id;
|
||||||
// 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<IChartEventItem>) => {
|
changeEvent: (state, action: PayloadAction<IChartEventItem>) => {
|
||||||
state.dirty = true;
|
state.dirty = true;
|
||||||
state.series = state.series.map((event) => {
|
state.series = state.series.map((event) => {
|
||||||
const eventId = 'type' in event ? event.id : (event as IChartEvent).id;
|
if (event.id === action.payload.id) {
|
||||||
if (eventId === action.payload.id) {
|
|
||||||
return action.payload;
|
return action.payload;
|
||||||
}
|
}
|
||||||
return event;
|
return event;
|
||||||
@@ -307,8 +291,7 @@ export const {
|
|||||||
ready,
|
ready,
|
||||||
setReport,
|
setReport,
|
||||||
setName,
|
setName,
|
||||||
addEvent,
|
addSerie,
|
||||||
addFormula,
|
|
||||||
removeEvent,
|
removeEvent,
|
||||||
duplicateEvent,
|
duplicateEvent,
|
||||||
changeEvent,
|
changeEvent,
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { Combobox } from '@/components/ui/combobox';
|
import { Combobox } from '@/components/ui/combobox';
|
||||||
import { useAppParams } from '@/hooks/use-app-params';
|
import { useAppParams } from '@/hooks/use-app-params';
|
||||||
import { useEventProperties } from '@/hooks/use-event-properties';
|
import { useEventProperties } from '@/hooks/use-event-properties';
|
||||||
import { useDispatch, useSelector } from '@/redux';
|
import { useDispatch } from '@/redux';
|
||||||
import { api } from '@/trpc/client';
|
|
||||||
import { cn } from '@/utils/cn';
|
import { cn } from '@/utils/cn';
|
||||||
import { DatabaseIcon } from 'lucide-react';
|
import { DatabaseIcon } from 'lucide-react';
|
||||||
|
|
||||||
@@ -43,6 +42,7 @@ export function EventPropertiesCombobox({
|
|||||||
changeEvent({
|
changeEvent({
|
||||||
...event,
|
...event,
|
||||||
property: value,
|
property: value,
|
||||||
|
type: 'event',
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { ColorSquare } from '@/components/color-square';
|
import { ColorSquare } from '@/components/color-square';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
import { ComboboxEvents } from '@/components/ui/combobox-events';
|
import { ComboboxEvents } from '@/components/ui/combobox-events';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { InputEnter } from '@/components/ui/input-enter';
|
||||||
import { useAppParams } from '@/hooks/use-app-params';
|
import { useAppParams } from '@/hooks/use-app-params';
|
||||||
import { useDebounceFn } from '@/hooks/use-debounce-fn';
|
import { useDebounceFn } from '@/hooks/use-debounce-fn';
|
||||||
import { useEventNames } from '@/hooks/use-event-names';
|
import { useEventNames } from '@/hooks/use-event-names';
|
||||||
@@ -23,19 +25,16 @@ 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, IChartEventItem, IChartFormula } from '@openpanel/validation';
|
import type { IChartEventItem, IChartFormula } from '@openpanel/validation';
|
||||||
import { FilterIcon, HandIcon, PlusIcon } from 'lucide-react';
|
import { FilterIcon, HandIcon, PlusIcon } from 'lucide-react';
|
||||||
import { ReportSegment } from '../ReportSegment';
|
import { ReportSegment } from '../ReportSegment';
|
||||||
import {
|
import {
|
||||||
addEvent,
|
addSerie,
|
||||||
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';
|
||||||
@@ -50,28 +49,22 @@ function SortableEvent({
|
|||||||
isSelectManyEvents,
|
isSelectManyEvents,
|
||||||
...props
|
...props
|
||||||
}: {
|
}: {
|
||||||
event: IChartEventItem | IChartEvent;
|
event: IChartEventItem;
|
||||||
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: eventId ?? '' });
|
useSortable({ id: event.id ?? '' });
|
||||||
|
|
||||||
const style = {
|
const style = {
|
||||||
transform: CSS.Transform.toString(transform),
|
transform: CSS.Transform.toString(transform),
|
||||||
transition,
|
transition,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Normalize event to have type field
|
const isEvent = event.type === 'event';
|
||||||
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}>
|
||||||
@@ -88,15 +81,15 @@ function SortableEvent({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Segment and Filter buttons - only for events */}
|
{/* Segment and Filter buttons - only for events */}
|
||||||
{chartEvent && (showSegment || showAddFilter) && (
|
{isEvent && (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={chartEvent.segment}
|
value={event.segment}
|
||||||
onChange={(segment) => {
|
onChange={(segment) => {
|
||||||
dispatch(
|
dispatch(
|
||||||
changeEvent({
|
changeEvent({
|
||||||
...chartEvent,
|
...event,
|
||||||
segment,
|
segment,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -105,13 +98,13 @@ function SortableEvent({
|
|||||||
)}
|
)}
|
||||||
{showAddFilter && (
|
{showAddFilter && (
|
||||||
<PropertiesCombobox
|
<PropertiesCombobox
|
||||||
event={chartEvent}
|
event={event}
|
||||||
onSelect={(action) => {
|
onSelect={(action) => {
|
||||||
dispatch(
|
dispatch(
|
||||||
changeEvent({
|
changeEvent({
|
||||||
...chartEvent,
|
...event,
|
||||||
filters: [
|
filters: [
|
||||||
...chartEvent.filters,
|
...event.filters,
|
||||||
{
|
{
|
||||||
id: shortId(),
|
id: shortId(),
|
||||||
name: action.value,
|
name: action.value,
|
||||||
@@ -135,20 +128,20 @@ function SortableEvent({
|
|||||||
</PropertiesCombobox>
|
</PropertiesCombobox>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showSegment && chartEvent.segment.startsWith('property_') && (
|
{showSegment && event.segment.startsWith('property_') && (
|
||||||
<EventPropertiesCombobox event={chartEvent} />
|
<EventPropertiesCombobox event={event} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Filters - only for events */}
|
{/* Filters - only for events */}
|
||||||
{chartEvent && !isSelectManyEvents && <FiltersList event={chartEvent} />}
|
{isEvent && !isSelectManyEvents && <FiltersList event={event} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ReportEvents() {
|
export function ReportEvents() {
|
||||||
const selectedEvents = useSelector((state) => state.report.events);
|
const selectedEvents = useSelector((state) => state.report.series);
|
||||||
const chartType = useSelector((state) => state.report.chartType);
|
const chartType = useSelector((state) => state.report.chartType);
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const { projectId } = useAppParams();
|
const { projectId } = useAppParams();
|
||||||
@@ -162,7 +155,7 @@ export function ReportEvents() {
|
|||||||
const isAddEventDisabled =
|
const isAddEventDisabled =
|
||||||
(chartType === 'retention' || chartType === 'conversion') &&
|
(chartType === 'retention' || chartType === 'conversion') &&
|
||||||
selectedEvents.length >= 2;
|
selectedEvents.length >= 2;
|
||||||
const dispatchChangeEvent = useDebounceFn((event: IChartEvent) => {
|
const dispatchChangeEvent = useDebounceFn((event: IChartEventItem) => {
|
||||||
dispatch(changeEvent(event));
|
dispatch(changeEvent(event));
|
||||||
});
|
});
|
||||||
const isSelectManyEvents = chartType === 'retention';
|
const isSelectManyEvents = chartType === 'retention';
|
||||||
@@ -185,15 +178,18 @@ export function ReportEvents() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMore = (event: IChartEventItem | IChartEvent) => {
|
const handleMore = (event: IChartEventItem) => {
|
||||||
const callback: ReportEventMoreProps['onClick'] = (action) => {
|
const callback: ReportEventMoreProps['onClick'] = (action) => {
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case 'remove': {
|
case 'remove': {
|
||||||
return dispatch(removeEvent({ id: 'type' in event ? event.id : (event as IChartEvent).id }));
|
return dispatch(
|
||||||
|
removeEvent({
|
||||||
|
id: event.id,
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
case 'duplicate': {
|
case 'duplicate': {
|
||||||
const normalized = 'type' in event ? event : { ...event, type: 'event' as const };
|
return dispatch(duplicateEvent(event));
|
||||||
return dispatch(duplicateEvent(normalized));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -205,7 +201,10 @@ export function ReportEvents() {
|
|||||||
dispatch(changeEvent(formula));
|
dispatch(changeEvent(formula));
|
||||||
});
|
});
|
||||||
|
|
||||||
const showFormula = chartType !== 'conversion' && chartType !== 'funnel' && chartType !== 'retention';
|
const showFormula =
|
||||||
|
chartType !== 'conversion' &&
|
||||||
|
chartType !== 'funnel' &&
|
||||||
|
chartType !== 'retention';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -216,20 +215,17 @@ export function ReportEvents() {
|
|||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
>
|
>
|
||||||
<SortableContext
|
<SortableContext
|
||||||
items={selectedEvents.map((e) => ({ id: ('type' in e ? e.id : (e as IChartEvent).id) ?? '' }))}
|
items={selectedEvents.map((e) => ({ id: e.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 isFormula = event.type === 'formula';
|
||||||
const normalized: IChartEventItem =
|
|
||||||
'type' in event ? event : { ...event, type: 'event' as const };
|
|
||||||
const isFormula = normalized.type === 'formula';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SortableEvent
|
<SortableEvent
|
||||||
key={normalized.id}
|
key={event.id}
|
||||||
event={normalized}
|
event={event}
|
||||||
index={index}
|
index={index}
|
||||||
showSegment={showSegment}
|
showSegment={showSegment}
|
||||||
showAddFilter={showAddFilter}
|
showAddFilter={showAddFilter}
|
||||||
@@ -241,10 +237,10 @@ export function ReportEvents() {
|
|||||||
<div className="flex-1 flex flex-col gap-2">
|
<div className="flex-1 flex flex-col gap-2">
|
||||||
<InputEnter
|
<InputEnter
|
||||||
placeholder="eg: A+B, A/B"
|
placeholder="eg: A+B, A/B"
|
||||||
value={normalized.formula}
|
value={event.formula}
|
||||||
onChangeValue={(value) => {
|
onChangeValue={(value) => {
|
||||||
dispatchChangeFormula({
|
dispatchChangeFormula({
|
||||||
...normalized,
|
...event,
|
||||||
formula: value,
|
formula: value,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
@@ -252,75 +248,75 @@ export function ReportEvents() {
|
|||||||
{showDisplayNameInput && (
|
{showDisplayNameInput && (
|
||||||
<Input
|
<Input
|
||||||
placeholder={`Formula (${alphabetIds[index]})`}
|
placeholder={`Formula (${alphabetIds[index]})`}
|
||||||
defaultValue={normalized.displayName}
|
defaultValue={event.displayName}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
dispatchChangeFormula({
|
dispatchChangeFormula({
|
||||||
...normalized,
|
...event,
|
||||||
displayName: e.target.value,
|
displayName: e.target.value,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<ReportEventMore onClick={handleMore(normalized)} />
|
<ReportEventMore onClick={handleMore(event)} />
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<ComboboxEvents
|
<ComboboxEvents
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
searchable
|
searchable
|
||||||
multiple={isSelectManyEvents as false}
|
multiple={isSelectManyEvents as false}
|
||||||
value={
|
value={
|
||||||
(isSelectManyEvents
|
isSelectManyEvents
|
||||||
? ((normalized as IChartEventItem & { type: 'event' }).filters[0]?.value ?? [])
|
? (event.filters[0]?.value ?? [])
|
||||||
: (normalized as IChartEventItem & { type: 'event' }).name) as any
|
: (event.name as any)
|
||||||
}
|
}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
dispatch(
|
dispatch(
|
||||||
changeEvent(
|
changeEvent(
|
||||||
Array.isArray(value)
|
Array.isArray(value)
|
||||||
? {
|
? {
|
||||||
id: normalized.id,
|
id: event.id,
|
||||||
type: 'event',
|
type: 'event',
|
||||||
segment: 'user',
|
segment: 'user',
|
||||||
filters: [
|
filters: [
|
||||||
{
|
{
|
||||||
name: 'name',
|
name: 'name',
|
||||||
operator: 'is',
|
operator: 'is',
|
||||||
value: value,
|
value: value,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
name: '*',
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
...event,
|
||||||
|
type: 'event',
|
||||||
|
name: value,
|
||||||
|
filters: [],
|
||||||
},
|
},
|
||||||
],
|
),
|
||||||
name: '*',
|
);
|
||||||
}
|
}}
|
||||||
: {
|
items={eventNames}
|
||||||
...normalized,
|
placeholder="Select event"
|
||||||
type: 'event',
|
/>
|
||||||
name: value,
|
{showDisplayNameInput && (
|
||||||
filters: [],
|
<Input
|
||||||
},
|
placeholder={
|
||||||
),
|
event.name
|
||||||
);
|
? `${event.name} (${alphabetIds[index]})`
|
||||||
}}
|
: 'Display name'
|
||||||
items={eventNames}
|
}
|
||||||
placeholder="Select event"
|
defaultValue={event.displayName}
|
||||||
/>
|
onChange={(e) => {
|
||||||
{showDisplayNameInput && (
|
dispatchChangeEvent({
|
||||||
<Input
|
...event,
|
||||||
placeholder={
|
displayName: e.target.value,
|
||||||
(normalized as IChartEventItem & { type: 'event' }).name
|
});
|
||||||
? `${(normalized as IChartEventItem & { type: 'event' }).name} (${alphabetIds[index]})`
|
}}
|
||||||
: 'Display name'
|
/>
|
||||||
}
|
)}
|
||||||
defaultValue={(normalized as IChartEventItem & { type: 'event' }).displayName}
|
<ReportEventMore onClick={handleMore(event)} />
|
||||||
onChange={(e) => {
|
|
||||||
dispatchChangeEvent({
|
|
||||||
...(normalized as IChartEventItem & { type: 'event' }),
|
|
||||||
displayName: e.target.value,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<ReportEventMore onClick={handleMore(normalized)} />
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</SortableEvent>
|
</SortableEvent>
|
||||||
@@ -328,38 +324,40 @@ export function ReportEvents() {
|
|||||||
})}
|
})}
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<ComboboxEvents
|
<ComboboxEvents
|
||||||
disabled={isAddEventDisabled}
|
disabled={isAddEventDisabled}
|
||||||
value={''}
|
value={''}
|
||||||
searchable
|
searchable
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
if (isSelectManyEvents) {
|
if (isSelectManyEvents) {
|
||||||
dispatch(
|
dispatch(
|
||||||
addEvent({
|
addSerie({
|
||||||
segment: 'user',
|
type: 'event',
|
||||||
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,
|
addSerie({
|
||||||
segment: 'event',
|
type: 'event',
|
||||||
filters: [],
|
name: value,
|
||||||
}),
|
segment: 'event',
|
||||||
);
|
filters: [],
|
||||||
}
|
}),
|
||||||
}}
|
);
|
||||||
placeholder="Select event"
|
}
|
||||||
items={eventNames}
|
}}
|
||||||
/>
|
placeholder="Select event"
|
||||||
|
items={eventNames}
|
||||||
|
/>
|
||||||
{showFormula && (
|
{showFormula && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -367,7 +365,7 @@ export function ReportEvents() {
|
|||||||
icon={PlusIcon}
|
icon={PlusIcon}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
dispatch(
|
dispatch(
|
||||||
addFormula({
|
addSerie({
|
||||||
type: 'formula',
|
type: 'formula',
|
||||||
formula: '',
|
formula: '',
|
||||||
displayName: '',
|
displayName: '',
|
||||||
|
|||||||
@@ -30,11 +30,10 @@ import type {
|
|||||||
IChartEventItem,
|
IChartEventItem,
|
||||||
IChartFormula,
|
IChartFormula,
|
||||||
} from '@openpanel/validation';
|
} from '@openpanel/validation';
|
||||||
import { FilterIcon, HandIcon, PiIcon, PlusIcon } from 'lucide-react';
|
import { FilterIcon, HandIcon, PiIcon } from 'lucide-react';
|
||||||
import { ReportSegment } from '../ReportSegment';
|
import { ReportSegment } from '../ReportSegment';
|
||||||
import {
|
import {
|
||||||
addEvent,
|
addSerie,
|
||||||
addFormula,
|
|
||||||
changeEvent,
|
changeEvent,
|
||||||
duplicateEvent,
|
duplicateEvent,
|
||||||
removeEvent,
|
removeEvent,
|
||||||
@@ -168,7 +167,7 @@ export function ReportSeries() {
|
|||||||
const isAddEventDisabled =
|
const isAddEventDisabled =
|
||||||
(chartType === 'retention' || chartType === 'conversion') &&
|
(chartType === 'retention' || chartType === 'conversion') &&
|
||||||
selectedSeries.length >= 2;
|
selectedSeries.length >= 2;
|
||||||
const dispatchChangeEvent = useDebounceFn((event: IChartEvent) => {
|
const dispatchChangeEvent = useDebounceFn((event: IChartEventItem) => {
|
||||||
dispatch(changeEvent(event));
|
dispatch(changeEvent(event));
|
||||||
});
|
});
|
||||||
const isSelectManyEvents = chartType === 'retention';
|
const isSelectManyEvents = chartType === 'retention';
|
||||||
@@ -361,7 +360,8 @@ export function ReportSeries() {
|
|||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
if (isSelectManyEvents) {
|
if (isSelectManyEvents) {
|
||||||
dispatch(
|
dispatch(
|
||||||
addEvent({
|
addSerie({
|
||||||
|
type: 'event',
|
||||||
segment: 'user',
|
segment: 'user',
|
||||||
name: value,
|
name: value,
|
||||||
filters: [
|
filters: [
|
||||||
@@ -375,7 +375,8 @@ export function ReportSeries() {
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
dispatch(
|
dispatch(
|
||||||
addEvent({
|
addSerie({
|
||||||
|
type: 'event',
|
||||||
name: value,
|
name: value,
|
||||||
segment: 'event',
|
segment: 'event',
|
||||||
filters: [],
|
filters: [],
|
||||||
@@ -393,7 +394,7 @@ export function ReportSeries() {
|
|||||||
icon={PiIcon}
|
icon={PiIcon}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
dispatch(
|
dispatch(
|
||||||
addFormula({
|
addSerie({
|
||||||
type: 'formula',
|
type: 'formula',
|
||||||
formula: '',
|
formula: '',
|
||||||
displayName: '',
|
displayName: '',
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { useSelector } from '@/redux';
|
|||||||
|
|
||||||
import { ReportBreakdowns } from './ReportBreakdowns';
|
import { ReportBreakdowns } from './ReportBreakdowns';
|
||||||
import { ReportSeries } from './ReportSeries';
|
import { ReportSeries } from './ReportSeries';
|
||||||
import { ReportFormula } from './ReportFormula';
|
|
||||||
import { ReportSettings } from './ReportSettings';
|
import { ReportSettings } from './ReportSettings';
|
||||||
|
|
||||||
export function ReportSidebar() {
|
export function ReportSidebar() {
|
||||||
|
|||||||
@@ -39,14 +39,12 @@ interface PureFilterProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function FilterItem({ filter, event }: FilterProps) {
|
export function FilterItem({ filter, event }: FilterProps) {
|
||||||
// const { range, startDate, endDate, interval } = useSelector(
|
|
||||||
// (state) => state.report,
|
|
||||||
// );
|
|
||||||
const onRemove = ({ id }: IChartEventFilter) => {
|
const onRemove = ({ id }: IChartEventFilter) => {
|
||||||
dispatch(
|
dispatch(
|
||||||
changeEvent({
|
changeEvent({
|
||||||
...event,
|
...event,
|
||||||
filters: event.filters.filter((item) => item.id !== id),
|
filters: event.filters.filter((item) => item.id !== id),
|
||||||
|
type: 'event',
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -58,6 +56,7 @@ export function FilterItem({ filter, event }: FilterProps) {
|
|||||||
dispatch(
|
dispatch(
|
||||||
changeEvent({
|
changeEvent({
|
||||||
...event,
|
...event,
|
||||||
|
type: 'event',
|
||||||
filters: event.filters.map((item) => {
|
filters: event.filters.map((item) => {
|
||||||
if (item.id === id) {
|
if (item.id === id) {
|
||||||
return {
|
return {
|
||||||
@@ -79,6 +78,7 @@ export function FilterItem({ filter, event }: FilterProps) {
|
|||||||
dispatch(
|
dispatch(
|
||||||
changeEvent({
|
changeEvent({
|
||||||
...event,
|
...event,
|
||||||
|
type: 'event',
|
||||||
filters: event.filters.map((item) => {
|
filters: event.filters.map((item) => {
|
||||||
if (item.id === id) {
|
if (item.id === id) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -56,9 +56,8 @@ export default function AddNotificationRule({ rule }: Props) {
|
|||||||
template: rule?.template ?? '',
|
template: rule?.template ?? '',
|
||||||
config: rule?.config ?? {
|
config: rule?.config ?? {
|
||||||
type: 'events',
|
type: 'events',
|
||||||
series: [
|
events: [
|
||||||
{
|
{
|
||||||
type: 'event',
|
|
||||||
name: '',
|
name: '',
|
||||||
segment: 'event',
|
segment: 'event',
|
||||||
filters: [],
|
filters: [],
|
||||||
|
|||||||
@@ -341,13 +341,14 @@ export default function EventDetails({ id, createdAt, projectId }: Props) {
|
|||||||
<ReportChartShortcut
|
<ReportChartShortcut
|
||||||
projectId={event.projectId}
|
projectId={event.projectId}
|
||||||
chartType="linear"
|
chartType="linear"
|
||||||
events={[
|
series={[
|
||||||
{
|
{
|
||||||
id: 'A',
|
id: 'A',
|
||||||
name: event.name,
|
name: event.name,
|
||||||
displayName: 'Similar events',
|
displayName: 'Similar events',
|
||||||
segment: 'event',
|
segment: 'event',
|
||||||
filters: [],
|
filters: [],
|
||||||
|
type: 'event',
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
useEventQueryNamesFilter,
|
useEventQueryNamesFilter,
|
||||||
} from '@/hooks/use-event-query-filters';
|
} from '@/hooks/use-event-query-filters';
|
||||||
|
|
||||||
import type { IChartEvent } from '@openpanel/validation';
|
import type { IChartEventItem } from '@openpanel/validation';
|
||||||
|
|
||||||
import { createFileRoute } from '@tanstack/react-router';
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
|
|
||||||
@@ -23,13 +23,14 @@ function Component() {
|
|||||||
const { projectId } = Route.useParams();
|
const { projectId } = Route.useParams();
|
||||||
const [filters] = useEventQueryFilters();
|
const [filters] = useEventQueryFilters();
|
||||||
const [events] = useEventQueryNamesFilter();
|
const [events] = useEventQueryNamesFilter();
|
||||||
const fallback: IChartEvent[] = [
|
const fallback: IChartEventItem[] = [
|
||||||
{
|
{
|
||||||
id: 'A',
|
id: 'A',
|
||||||
name: '*',
|
name: '*',
|
||||||
displayName: 'All events',
|
displayName: 'All events',
|
||||||
segment: 'event',
|
segment: 'event',
|
||||||
filters: filters ?? [],
|
filters: filters ?? [],
|
||||||
|
type: 'event',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -49,7 +50,7 @@ function Component() {
|
|||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
range="30d"
|
range="30d"
|
||||||
chartType="histogram"
|
chartType="histogram"
|
||||||
events={
|
series={
|
||||||
events && events.length > 0
|
events && events.length > 0
|
||||||
? events.map((name) => ({
|
? events.map((name) => ({
|
||||||
id: name,
|
id: name,
|
||||||
@@ -57,6 +58,7 @@ function Component() {
|
|||||||
displayName: name,
|
displayName: name,
|
||||||
segment: 'event',
|
segment: 'event',
|
||||||
filters: filters ?? [],
|
filters: filters ?? [],
|
||||||
|
type: 'event',
|
||||||
}))
|
}))
|
||||||
: fallback
|
: fallback
|
||||||
}
|
}
|
||||||
@@ -78,7 +80,7 @@ function Component() {
|
|||||||
name: 'name',
|
name: 'name',
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
events={
|
series={
|
||||||
events && events.length > 0
|
events && events.length > 0
|
||||||
? events.map((name) => ({
|
? events.map((name) => ({
|
||||||
id: name,
|
id: name,
|
||||||
@@ -86,6 +88,7 @@ function Component() {
|
|||||||
displayName: name,
|
displayName: name,
|
||||||
segment: 'event',
|
segment: 'event',
|
||||||
filters: filters ?? [],
|
filters: filters ?? [],
|
||||||
|
type: 'event',
|
||||||
}))
|
}))
|
||||||
: [
|
: [
|
||||||
{
|
{
|
||||||
@@ -94,6 +97,7 @@ function Component() {
|
|||||||
displayName: 'All events',
|
displayName: 'All events',
|
||||||
segment: 'event',
|
segment: 'event',
|
||||||
filters: filters ?? [],
|
filters: filters ?? [],
|
||||||
|
type: 'event',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -115,7 +119,7 @@ function Component() {
|
|||||||
name: 'name',
|
name: 'name',
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
events={
|
series={
|
||||||
events && events.length > 0
|
events && events.length > 0
|
||||||
? events.map((name) => ({
|
? events.map((name) => ({
|
||||||
id: name,
|
id: name,
|
||||||
@@ -123,6 +127,7 @@ function Component() {
|
|||||||
displayName: name,
|
displayName: name,
|
||||||
segment: 'event',
|
segment: 'event',
|
||||||
filters: filters ?? [],
|
filters: filters ?? [],
|
||||||
|
type: 'event',
|
||||||
}))
|
}))
|
||||||
: [
|
: [
|
||||||
{
|
{
|
||||||
@@ -131,6 +136,7 @@ function Component() {
|
|||||||
displayName: 'All events',
|
displayName: 'All events',
|
||||||
segment: 'event',
|
segment: 'event',
|
||||||
filters: filters ?? [],
|
filters: filters ?? [],
|
||||||
|
type: 'event',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -152,7 +158,7 @@ function Component() {
|
|||||||
name: 'name',
|
name: 'name',
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
events={
|
series={
|
||||||
events && events.length > 0
|
events && events.length > 0
|
||||||
? events.map((name) => ({
|
? events.map((name) => ({
|
||||||
id: name,
|
id: name,
|
||||||
@@ -160,6 +166,7 @@ function Component() {
|
|||||||
displayName: name,
|
displayName: name,
|
||||||
segment: 'event',
|
segment: 'event',
|
||||||
filters: filters ?? [],
|
filters: filters ?? [],
|
||||||
|
type: 'event',
|
||||||
}))
|
}))
|
||||||
: [
|
: [
|
||||||
{
|
{
|
||||||
@@ -168,6 +175,7 @@ function Component() {
|
|||||||
displayName: 'All events',
|
displayName: 'All events',
|
||||||
segment: 'event',
|
segment: 'event',
|
||||||
filters: filters ?? [],
|
filters: filters ?? [],
|
||||||
|
type: 'event',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
104
packages/db/code-migrations/7-migrate-events-to-series.ts
Normal file
104
packages/db/code-migrations/7-migrate-events-to-series.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { shortId } from '@openpanel/common';
|
||||||
|
import type {
|
||||||
|
IChartEvent,
|
||||||
|
IChartEventItem,
|
||||||
|
IChartFormula,
|
||||||
|
} from '@openpanel/validation';
|
||||||
|
import { db } from '../index';
|
||||||
|
import { printBoxMessage } from './helpers';
|
||||||
|
|
||||||
|
export async function up() {
|
||||||
|
printBoxMessage('🔄 Migrating Events to Series Format', []);
|
||||||
|
|
||||||
|
// Get all reports
|
||||||
|
const reports = await db.report.findMany({
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
events: true,
|
||||||
|
formula: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let migratedCount = 0;
|
||||||
|
let skippedCount = 0;
|
||||||
|
let formulaAddedCount = 0;
|
||||||
|
|
||||||
|
for (const report of reports) {
|
||||||
|
const events = report.events as unknown as Array<
|
||||||
|
Partial<IChartEventItem> | Partial<IChartEvent>
|
||||||
|
>;
|
||||||
|
const oldFormula = report.formula;
|
||||||
|
|
||||||
|
// Check if any event is missing the 'type' field (old format)
|
||||||
|
const needsEventMigration =
|
||||||
|
Array.isArray(events) &&
|
||||||
|
events.length > 0 &&
|
||||||
|
events.some(
|
||||||
|
(event) => !event || typeof event !== 'object' || !('type' in event),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if formula exists and isn't already in the series
|
||||||
|
const hasFormulaInSeries =
|
||||||
|
Array.isArray(events) &&
|
||||||
|
events.some(
|
||||||
|
(item) =>
|
||||||
|
item &&
|
||||||
|
typeof item === 'object' &&
|
||||||
|
'type' in item &&
|
||||||
|
item.type === 'formula',
|
||||||
|
);
|
||||||
|
|
||||||
|
const needsFormulaMigration = !!oldFormula && !hasFormulaInSeries;
|
||||||
|
|
||||||
|
// Skip if no migration needed
|
||||||
|
if (!needsEventMigration && !needsFormulaMigration) {
|
||||||
|
skippedCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transform events to new format: add type: 'event' to each event
|
||||||
|
const migratedSeries: IChartEventItem[] = Array.isArray(events)
|
||||||
|
? events.map((event) => {
|
||||||
|
if (event && typeof event === 'object' && 'type' in event) {
|
||||||
|
return event as IChartEventItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...event,
|
||||||
|
type: 'event',
|
||||||
|
} as IChartEventItem;
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// Add formula to series if it exists and isn't already there
|
||||||
|
if (needsFormulaMigration && oldFormula) {
|
||||||
|
const formulaItem: IChartFormula = {
|
||||||
|
type: 'formula',
|
||||||
|
formula: oldFormula,
|
||||||
|
id: shortId(),
|
||||||
|
};
|
||||||
|
migratedSeries.push(formulaItem);
|
||||||
|
formulaAddedCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`Updating report ${report.name} (${report.id}) with ${migratedSeries.length} series`,
|
||||||
|
);
|
||||||
|
// Update the report with migrated series
|
||||||
|
await db.report.update({
|
||||||
|
where: { id: report.id },
|
||||||
|
data: {
|
||||||
|
events: migratedSeries,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
migratedCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
printBoxMessage('✅ Migration Complete', [
|
||||||
|
`Migrated: ${migratedCount} reports`,
|
||||||
|
`Formulas added: ${formulaAddedCount} reports`,
|
||||||
|
`Skipped: ${skippedCount} reports (already in new format or empty)`,
|
||||||
|
]);
|
||||||
|
}
|
||||||
@@ -1,21 +1,15 @@
|
|||||||
import { slug } from '@openpanel/common';
|
import { slug } from '@openpanel/common';
|
||||||
import { alphabetIds } from '@openpanel/constants';
|
import { alphabetIds } from '@openpanel/constants';
|
||||||
import type {
|
import type { IChartEventItem } from '@openpanel/validation';
|
||||||
IChartBreakdown,
|
|
||||||
IChartEvent,
|
|
||||||
IChartEventItem,
|
|
||||||
} from '@openpanel/validation';
|
|
||||||
import { getSettingsForProject } from '../services/organization.service';
|
import { getSettingsForProject } from '../services/organization.service';
|
||||||
import type { ConcreteSeries, Plan } from './types';
|
|
||||||
import type { NormalizedInput } from './normalize';
|
import type { NormalizedInput } from './normalize';
|
||||||
|
import type { ConcreteSeries, Plan } from './types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create an execution plan from normalized input
|
* Create an execution plan from normalized input
|
||||||
* This sets up ConcreteSeries placeholders - actual breakdown expansion happens during fetch
|
* This sets up ConcreteSeries placeholders - actual breakdown expansion happens during fetch
|
||||||
*/
|
*/
|
||||||
export async function plan(
|
export async function plan(normalized: NormalizedInput): Promise<Plan> {
|
||||||
normalized: NormalizedInput,
|
|
||||||
): Promise<Plan> {
|
|
||||||
const { timezone } = await getSettingsForProject(normalized.projectId);
|
const { timezone } = await getSettingsForProject(normalized.projectId);
|
||||||
|
|
||||||
const concreteSeries: ConcreteSeries[] = [];
|
const concreteSeries: ConcreteSeries[] = [];
|
||||||
@@ -24,7 +18,7 @@ export async function plan(
|
|||||||
normalized.series.forEach((definition, index) => {
|
normalized.series.forEach((definition, index) => {
|
||||||
if (definition.type === 'event') {
|
if (definition.type === 'event') {
|
||||||
const event = definition as IChartEventItem & { type: 'event' };
|
const event = definition as IChartEventItem & { type: 'event' };
|
||||||
|
|
||||||
// For events, create a placeholder
|
// For events, create a placeholder
|
||||||
// If breakdowns exist, fetch will return multiple series (one per breakdown value)
|
// If breakdowns exist, fetch will return multiple series (one per breakdown value)
|
||||||
// If no breakdowns, fetch will return one series
|
// If no breakdowns, fetch will return one series
|
||||||
@@ -54,6 +48,3 @@ export async function plan(
|
|||||||
timezone,
|
timezone,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export type NormalizedInput = Awaited<ReturnType<typeof import('./normalize').normalize>>;
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
getEventFiltersWhereClause,
|
getEventFiltersWhereClause,
|
||||||
getSelectPropertyKey,
|
getSelectPropertyKey,
|
||||||
} from './chart.service';
|
} from './chart.service';
|
||||||
|
import { onlyReportEvents } from './reports.service';
|
||||||
|
|
||||||
export class ConversionService {
|
export class ConversionService {
|
||||||
constructor(private client: typeof ch) {}
|
constructor(private client: typeof ch) {}
|
||||||
@@ -18,7 +19,6 @@ export class ConversionService {
|
|||||||
funnelGroup,
|
funnelGroup,
|
||||||
funnelWindow = 24,
|
funnelWindow = 24,
|
||||||
series,
|
series,
|
||||||
events, // Backward compatibility - use series if available
|
|
||||||
breakdowns = [],
|
breakdowns = [],
|
||||||
interval,
|
interval,
|
||||||
timezone,
|
timezone,
|
||||||
@@ -31,12 +31,9 @@ export class ConversionService {
|
|||||||
);
|
);
|
||||||
const breakdownGroupBy = breakdowns.map((b, index) => `b_${index}`);
|
const breakdownGroupBy = breakdowns.map((b, index) => `b_${index}`);
|
||||||
|
|
||||||
// Use series if available, otherwise fall back to events (backward compat)
|
const events = onlyReportEvents(series);
|
||||||
const eventSeries = (series ?? events ?? []).filter(
|
|
||||||
(item): item is IChartEvent => item.type === 'event',
|
|
||||||
) as IChartEvent[];
|
|
||||||
|
|
||||||
if (eventSeries.length !== 2) {
|
if (events.length !== 2) {
|
||||||
throw new Error('events must be an array of two events');
|
throw new Error('events must be an array of two events');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,8 +41,8 @@ export class ConversionService {
|
|||||||
throw new Error('startDate and endDate are required');
|
throw new Error('startDate and endDate are required');
|
||||||
}
|
}
|
||||||
|
|
||||||
const eventA = eventSeries[0]!;
|
const eventA = events[0]!;
|
||||||
const eventB = eventSeries[1]!;
|
const eventB = events[1]!;
|
||||||
const whereA = Object.values(
|
const whereA = Object.values(
|
||||||
getEventFiltersWhereClause(eventA.filters),
|
getEventFiltersWhereClause(eventA.filters),
|
||||||
).join(' AND ');
|
).join(' AND ');
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
getEventFiltersWhereClause,
|
getEventFiltersWhereClause,
|
||||||
getSelectPropertyKey,
|
getSelectPropertyKey,
|
||||||
} from './chart.service';
|
} from './chart.service';
|
||||||
|
import { onlyReportEvents } from './reports.service';
|
||||||
|
|
||||||
export class FunnelService {
|
export class FunnelService {
|
||||||
constructor(private client: typeof ch) {}
|
constructor(private client: typeof ch) {}
|
||||||
@@ -179,7 +180,6 @@ export class FunnelService {
|
|||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
series,
|
series,
|
||||||
events, // Backward compatibility - use series if available
|
|
||||||
funnelWindow = 24,
|
funnelWindow = 24,
|
||||||
funnelGroup,
|
funnelGroup,
|
||||||
breakdowns = [],
|
breakdowns = [],
|
||||||
@@ -189,12 +189,7 @@ export class FunnelService {
|
|||||||
throw new Error('startDate and endDate are required');
|
throw new Error('startDate and endDate are required');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use series if available, otherwise fall back to events (backward compat)
|
const eventSeries = onlyReportEvents(series);
|
||||||
const rawSeries = (series ?? events ?? []) as IChartEventItem[];
|
|
||||||
const eventSeries = rawSeries.filter(
|
|
||||||
(item): item is IChartEventItem & { type: 'event' } =>
|
|
||||||
item.type === 'event',
|
|
||||||
) as IChartEvent[];
|
|
||||||
|
|
||||||
if (eventSeries.length === 0) {
|
if (eventSeries.length === 0) {
|
||||||
throw new Error('events are required');
|
throw new Error('events are required');
|
||||||
|
|||||||
@@ -5,21 +5,25 @@ import {
|
|||||||
} from '@openpanel/constants';
|
} from '@openpanel/constants';
|
||||||
import type {
|
import type {
|
||||||
IChartBreakdown,
|
IChartBreakdown,
|
||||||
IChartEvent,
|
|
||||||
IChartEventFilter,
|
IChartEventFilter,
|
||||||
IChartEventItem,
|
IChartEventItem,
|
||||||
IChartFormula,
|
|
||||||
IChartLineType,
|
IChartLineType,
|
||||||
IChartProps,
|
IChartProps,
|
||||||
IChartRange,
|
IChartRange,
|
||||||
ICriteria,
|
ICriteria,
|
||||||
} from '@openpanel/validation';
|
} from '@openpanel/validation';
|
||||||
|
|
||||||
import { db } from '../prisma-client';
|
|
||||||
import type { Report as DbReport, ReportLayout } from '../prisma-client';
|
import type { Report as DbReport, ReportLayout } from '../prisma-client';
|
||||||
|
import { db } from '../prisma-client';
|
||||||
|
|
||||||
export type IServiceReport = Awaited<ReturnType<typeof getReportById>>;
|
export type IServiceReport = Awaited<ReturnType<typeof getReportById>>;
|
||||||
|
|
||||||
|
export const onlyReportEvents = (
|
||||||
|
series: NonNullable<IServiceReport>['series'],
|
||||||
|
) => {
|
||||||
|
return series.filter((item) => item.type === 'event');
|
||||||
|
};
|
||||||
|
|
||||||
export function transformFilter(
|
export function transformFilter(
|
||||||
filter: Partial<IChartEventFilter>,
|
filter: Partial<IChartEventFilter>,
|
||||||
index: number,
|
index: number,
|
||||||
@@ -34,72 +38,39 @@ export function transformFilter(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function transformReportEventItem(
|
export function transformReportEventItem(
|
||||||
item: Partial<IChartEventItem> | Partial<IChartEvent>,
|
item: IChartEventItem,
|
||||||
index: number,
|
index: number,
|
||||||
): IChartEventItem {
|
): IChartEventItem {
|
||||||
// If item already has type field, it's the new format
|
if (item.type === 'formula') {
|
||||||
if (item && typeof item === 'object' && 'type' in item) {
|
// Transform formula
|
||||||
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 {
|
return {
|
||||||
type: 'event',
|
type: 'formula',
|
||||||
segment: event.segment ?? 'event',
|
id: item.id ?? alphabetIds[index]!,
|
||||||
filters: (event.filters ?? []).map(transformFilter),
|
formula: item.formula || '',
|
||||||
id: event.id ?? alphabetIds[index]!,
|
displayName: item.displayName,
|
||||||
name: event.name || 'unknown_event',
|
|
||||||
displayName: event.displayName,
|
|
||||||
property: event.property,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Old format without type field - assume it's an event
|
// Transform event with type field
|
||||||
const event = item as Partial<IChartEvent>;
|
|
||||||
return {
|
return {
|
||||||
type: 'event',
|
type: 'event',
|
||||||
segment: event.segment ?? 'event',
|
segment: item.segment ?? 'event',
|
||||||
filters: (event.filters ?? []).map(transformFilter),
|
filters: (item.filters ?? []).map(transformFilter),
|
||||||
id: event.id ?? alphabetIds[index]!,
|
id: item.id ?? alphabetIds[index]!,
|
||||||
name: event.name || 'unknown_event',
|
name: item.name || 'unknown_event',
|
||||||
displayName: event.displayName,
|
displayName: item.displayName,
|
||||||
property: event.property,
|
property: item.property,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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,
|
||||||
series: eventsData.map(transformReportEventItem),
|
series:
|
||||||
|
(report.events as IChartEventItem[]).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,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
getProfilesCached,
|
getProfilesCached,
|
||||||
getSelectPropertyKey,
|
getSelectPropertyKey,
|
||||||
getSettingsForProject,
|
getSettingsForProject,
|
||||||
|
onlyReportEvents,
|
||||||
} from '@openpanel/db';
|
} from '@openpanel/db';
|
||||||
import {
|
import {
|
||||||
type IChartEvent,
|
type IChartEvent,
|
||||||
@@ -611,9 +612,7 @@ export const chartRouter = createTRPCRouter({
|
|||||||
// });
|
// });
|
||||||
|
|
||||||
// Get unique profile IDs
|
// Get unique profile IDs
|
||||||
console.log('profileIdsQuery', getSql());
|
|
||||||
const profileIds = await chQuery<{ profile_id: string }>(getSql());
|
const profileIds = await chQuery<{ profile_id: string }>(getSql());
|
||||||
console.log('profileIds', profileIds.length);
|
|
||||||
if (profileIds.length === 0) {
|
if (profileIds.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -663,10 +662,7 @@ export const chartRouter = createTRPCRouter({
|
|||||||
// stepIndex is 0-based, but level is 1-based, so we need level >= stepIndex + 1
|
// stepIndex is 0-based, but level is 1-based, so we need level >= stepIndex + 1
|
||||||
const targetLevel = stepIndex + 1;
|
const targetLevel = stepIndex + 1;
|
||||||
|
|
||||||
const eventSeries = series.filter(
|
const eventSeries = onlyReportEvents(series);
|
||||||
(item): item is typeof item & { type: 'event' } =>
|
|
||||||
item.type === 'event',
|
|
||||||
);
|
|
||||||
|
|
||||||
if (eventSeries.length === 0) {
|
if (eventSeries.length === 0) {
|
||||||
throw new Error('At least one event series is required');
|
throw new Error('At least one event series is required');
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import type { z } from 'zod';
|
import type { z } from 'zod';
|
||||||
|
|
||||||
|
export type UnionOmit<T, K extends keyof any> = T extends any
|
||||||
|
? Omit<T, K>
|
||||||
|
: never;
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
zChartBreakdown,
|
zChartBreakdown,
|
||||||
zChartEvent,
|
zChartEvent,
|
||||||
|
|||||||
Reference in New Issue
Block a user