This commit is contained in:
Carl-Gerhard Lindesvärd
2025-11-24 15:50:28 +01:00
parent 548747d826
commit 1fa61b1ae9
20 changed files with 321 additions and 295 deletions

View File

@@ -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(

View File

@@ -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 {

View File

@@ -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();

View File

@@ -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',
}} }}

View File

@@ -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,

View File

@@ -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',
}), }),
); );
}} }}

View File

@@ -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: '',

View File

@@ -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: '',

View File

@@ -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() {

View File

@@ -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 {

View File

@@ -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: [],

View File

@@ -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',
}, },
]} ]}
/> />

View File

@@ -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',
}, },
] ]
} }

View 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)`,
]);
}

View File

@@ -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>>;

View File

@@ -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 ');

View File

@@ -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');

View File

@@ -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,

View File

@@ -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');

View File

@@ -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,