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,
} from '@openpanel/db';
import { ChartEngine } from '@openpanel/db';
import {
zChartEvent,
zChartInput,
zChartInputBase,
} from '@openpanel/validation';
import { zChartEvent, zChartInputBase } from '@openpanel/validation';
import { omit } from 'ramda';
async function getProjectId(

View File

@@ -1,12 +1,4 @@
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 { useNumber } from '@/hooks/use-numer-formatter';
import { useSelector } from '@/redux';
@@ -28,8 +20,8 @@ import {
} from '@tanstack/react-virtual';
import throttle from 'lodash.throttle';
import { ChevronDown, ChevronRight } from 'lucide-react';
import { memo, useEffect, useMemo, useRef, useState } from 'react';
import type * as React from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { ReportTableToolbar } from './report-table-toolbar';
import {

View File

@@ -30,16 +30,7 @@ interface Props {
export function Chart({ data }: Props) {
const {
report: {
previous,
interval,
projectId,
startDate,
endDate,
range,
lineType,
events,
},
report: { interval, projectId, startDate, endDate, range, lineType },
isEditMode,
options: { hideXAxis, hideYAxis, maxDomain },
} = useReportChartContext();

View File

@@ -7,7 +7,7 @@ type ChartRootShortcutProps = Omit<ReportChartProps, 'report'> & {
previous?: ReportChartProps['report']['previous'];
chartType?: ReportChartProps['report']['chartType'];
interval?: ReportChartProps['report']['interval'];
events: ReportChartProps['report']['events'];
series: ReportChartProps['report']['series'];
breakdowns?: ReportChartProps['report']['breakdowns'];
lineType?: ReportChartProps['report']['lineType'];
};
@@ -18,7 +18,7 @@ export const ReportChartShortcut = ({
previous = false,
chartType = 'linear',
interval = 'day',
events,
series,
breakdowns,
lineType = 'monotone',
options,
@@ -33,7 +33,7 @@ export const ReportChartShortcut = ({
previous,
chartType,
interval,
events,
series,
lineType,
metric: 'sum',
}}

View File

@@ -11,7 +11,6 @@ import {
} from '@openpanel/constants';
import type {
IChartBreakdown,
IChartEvent,
IChartEventItem,
IChartFormula,
IChartLineType,
@@ -19,6 +18,7 @@ import type {
IChartRange,
IChartType,
IInterval,
UnionOmit,
zCriteria,
} from '@openpanel/validation';
import type { z } from 'zod';
@@ -89,37 +89,26 @@ export const reportSlice = createSlice({
state.name = action.payload;
},
// Series (Events and Formulas)
addEvent: (state, action: PayloadAction<Omit<IChartEvent, 'id'>>) => {
state.dirty = true;
state.series.push({
id: shortId(),
type: 'event',
...action.payload,
} as IChartEventItem);
},
addFormula: (
addSerie: (
state,
action: PayloadAction<Omit<IChartFormula, 'id'>>,
action: PayloadAction<UnionOmit<IChartEventItem, 'id'>>,
) => {
state.dirty = true;
state.series.push({
id: shortId(),
...action.payload,
} as IChartEventItem);
});
},
duplicateEvent: (
state,
action: PayloadAction<IChartEventItem>,
) => {
duplicateEvent: (state, action: PayloadAction<IChartEventItem>) => {
state.dirty = true;
if (action.payload.type === 'event') {
state.series.push({
...action.payload,
filters: action.payload.filters.map((filter) => ({
...filter,
...action.payload,
filters: action.payload.filters.map((filter) => ({
...filter,
id: shortId(),
})),
id: shortId(),
})),
id: shortId(),
} as IChartEventItem);
} else {
state.series.push({
@@ -135,19 +124,14 @@ export const reportSlice = createSlice({
}>,
) => {
state.dirty = true;
state.series = state.series.filter(
(event) => {
// Handle both old format (no type) and new format
const eventId = 'type' in event ? event.id : (event as IChartEvent).id;
return eventId !== action.payload.id;
},
);
state.series = state.series.filter((event) => {
return event.id !== action.payload.id;
});
},
changeEvent: (state, action: PayloadAction<IChartEventItem>) => {
state.dirty = true;
state.series = state.series.map((event) => {
const eventId = 'type' in event ? event.id : (event as IChartEvent).id;
if (eventId === action.payload.id) {
if (event.id === action.payload.id) {
return action.payload;
}
return event;
@@ -307,8 +291,7 @@ export const {
ready,
setReport,
setName,
addEvent,
addFormula,
addSerie,
removeEvent,
duplicateEvent,
changeEvent,

View File

@@ -1,8 +1,7 @@
import { Combobox } from '@/components/ui/combobox';
import { useAppParams } from '@/hooks/use-app-params';
import { useEventProperties } from '@/hooks/use-event-properties';
import { useDispatch, useSelector } from '@/redux';
import { api } from '@/trpc/client';
import { useDispatch } from '@/redux';
import { cn } from '@/utils/cn';
import { DatabaseIcon } from 'lucide-react';
@@ -43,6 +42,7 @@ export function EventPropertiesCombobox({
changeEvent({
...event,
property: value,
type: 'event',
}),
);
}}

View File

@@ -1,6 +1,8 @@
import { ColorSquare } from '@/components/color-square';
import { Button } from '@/components/ui/button';
import { ComboboxEvents } from '@/components/ui/combobox-events';
import { Input } from '@/components/ui/input';
import { InputEnter } from '@/components/ui/input-enter';
import { useAppParams } from '@/hooks/use-app-params';
import { useDebounceFn } from '@/hooks/use-debounce-fn';
import { useEventNames } from '@/hooks/use-event-names';
@@ -23,19 +25,16 @@ import {
import { CSS } from '@dnd-kit/utilities';
import { shortId } from '@openpanel/common';
import { alphabetIds } from '@openpanel/constants';
import type { IChartEvent, IChartEventItem, IChartFormula } from '@openpanel/validation';
import type { IChartEventItem, IChartFormula } from '@openpanel/validation';
import { FilterIcon, HandIcon, PlusIcon } from 'lucide-react';
import { ReportSegment } from '../ReportSegment';
import {
addEvent,
addFormula,
addSerie,
changeEvent,
duplicateEvent,
removeEvent,
reorderEvents,
} from '../reportSlice';
import { InputEnter } from '@/components/ui/input-enter';
import { Button } from '@/components/ui/button';
import { EventPropertiesCombobox } from './EventPropertiesCombobox';
import { PropertiesCombobox } from './PropertiesCombobox';
import type { ReportEventMoreProps } from './ReportEventMore';
@@ -50,28 +49,22 @@ function SortableEvent({
isSelectManyEvents,
...props
}: {
event: IChartEventItem | IChartEvent;
event: IChartEventItem;
index: number;
showSegment: boolean;
showAddFilter: boolean;
isSelectManyEvents: boolean;
} & React.HTMLAttributes<HTMLDivElement>) {
const dispatch = useDispatch();
const eventId = 'type' in event ? event.id : (event as IChartEvent).id;
const { attributes, listeners, setNodeRef, transform, transition } =
useSortable({ id: eventId ?? '' });
useSortable({ id: event.id ?? '' });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
// Normalize event to have type field
const normalizedEvent: IChartEventItem =
'type' in event ? event : { ...event, type: 'event' as const };
const isFormula = normalizedEvent.type === 'formula';
const chartEvent = isFormula ? null : (normalizedEvent as IChartEventItem & { type: 'event' });
const isEvent = event.type === 'event';
return (
<div ref={setNodeRef} style={style} {...attributes} {...props}>
@@ -88,15 +81,15 @@ function SortableEvent({
</div>
{/* Segment and Filter buttons - only for events */}
{chartEvent && (showSegment || showAddFilter) && (
{isEvent && (showSegment || showAddFilter) && (
<div className="flex gap-2 p-2 pt-0">
{showSegment && (
<ReportSegment
value={chartEvent.segment}
value={event.segment}
onChange={(segment) => {
dispatch(
changeEvent({
...chartEvent,
...event,
segment,
}),
);
@@ -105,13 +98,13 @@ function SortableEvent({
)}
{showAddFilter && (
<PropertiesCombobox
event={chartEvent}
event={event}
onSelect={(action) => {
dispatch(
changeEvent({
...chartEvent,
...event,
filters: [
...chartEvent.filters,
...event.filters,
{
id: shortId(),
name: action.value,
@@ -135,20 +128,20 @@ function SortableEvent({
</PropertiesCombobox>
)}
{showSegment && chartEvent.segment.startsWith('property_') && (
<EventPropertiesCombobox event={chartEvent} />
{showSegment && event.segment.startsWith('property_') && (
<EventPropertiesCombobox event={event} />
)}
</div>
)}
{/* Filters - only for events */}
{chartEvent && !isSelectManyEvents && <FiltersList event={chartEvent} />}
{isEvent && !isSelectManyEvents && <FiltersList event={event} />}
</div>
);
}
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 dispatch = useDispatch();
const { projectId } = useAppParams();
@@ -162,7 +155,7 @@ export function ReportEvents() {
const isAddEventDisabled =
(chartType === 'retention' || chartType === 'conversion') &&
selectedEvents.length >= 2;
const dispatchChangeEvent = useDebounceFn((event: IChartEvent) => {
const dispatchChangeEvent = useDebounceFn((event: IChartEventItem) => {
dispatch(changeEvent(event));
});
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) => {
switch (action) {
case 'remove': {
return dispatch(removeEvent({ id: 'type' in event ? event.id : (event as IChartEvent).id }));
return dispatch(
removeEvent({
id: event.id,
}),
);
}
case 'duplicate': {
const normalized = 'type' in event ? event : { ...event, type: 'event' as const };
return dispatch(duplicateEvent(normalized));
return dispatch(duplicateEvent(event));
}
}
};
@@ -205,7 +201,10 @@ export function ReportEvents() {
dispatch(changeEvent(formula));
});
const showFormula = chartType !== 'conversion' && chartType !== 'funnel' && chartType !== 'retention';
const showFormula =
chartType !== 'conversion' &&
chartType !== 'funnel' &&
chartType !== 'retention';
return (
<div>
@@ -216,20 +215,17 @@ export function ReportEvents() {
onDragEnd={handleDragEnd}
>
<SortableContext
items={selectedEvents.map((e) => ({ id: ('type' in e ? e.id : (e as IChartEvent).id) ?? '' }))}
items={selectedEvents.map((e) => ({ id: e.id! }))}
strategy={verticalListSortingStrategy}
>
<div className="flex flex-col gap-4">
{selectedEvents.map((event, index) => {
// Normalize event to have type field
const normalized: IChartEventItem =
'type' in event ? event : { ...event, type: 'event' as const };
const isFormula = normalized.type === 'formula';
const isFormula = event.type === 'formula';
return (
<SortableEvent
key={normalized.id}
event={normalized}
key={event.id}
event={event}
index={index}
showSegment={showSegment}
showAddFilter={showAddFilter}
@@ -241,10 +237,10 @@ export function ReportEvents() {
<div className="flex-1 flex flex-col gap-2">
<InputEnter
placeholder="eg: A+B, A/B"
value={normalized.formula}
value={event.formula}
onChangeValue={(value) => {
dispatchChangeFormula({
...normalized,
...event,
formula: value,
});
}}
@@ -252,75 +248,75 @@ export function ReportEvents() {
{showDisplayNameInput && (
<Input
placeholder={`Formula (${alphabetIds[index]})`}
defaultValue={normalized.displayName}
defaultValue={event.displayName}
onChange={(e) => {
dispatchChangeFormula({
...normalized,
...event,
displayName: e.target.value,
});
}}
/>
)}
</div>
<ReportEventMore onClick={handleMore(normalized)} />
<ReportEventMore onClick={handleMore(event)} />
</>
) : (
<>
<ComboboxEvents
className="flex-1"
searchable
multiple={isSelectManyEvents as false}
value={
(isSelectManyEvents
? ((normalized as IChartEventItem & { type: 'event' }).filters[0]?.value ?? [])
: (normalized as IChartEventItem & { type: 'event' }).name) as any
}
onChange={(value) => {
dispatch(
changeEvent(
Array.isArray(value)
? {
id: normalized.id,
<ComboboxEvents
className="flex-1"
searchable
multiple={isSelectManyEvents as false}
value={
isSelectManyEvents
? (event.filters[0]?.value ?? [])
: (event.name as any)
}
onChange={(value) => {
dispatch(
changeEvent(
Array.isArray(value)
? {
id: event.id,
type: 'event',
segment: 'user',
filters: [
{
name: 'name',
operator: 'is',
value: value,
segment: 'user',
filters: [
{
name: 'name',
operator: 'is',
value: value,
},
],
name: '*',
}
: {
...event,
type: 'event',
name: value,
filters: [],
},
],
name: '*',
}
: {
...normalized,
type: 'event',
name: value,
filters: [],
},
),
);
}}
items={eventNames}
placeholder="Select event"
/>
{showDisplayNameInput && (
<Input
placeholder={
(normalized as IChartEventItem & { type: 'event' }).name
? `${(normalized as IChartEventItem & { type: 'event' }).name} (${alphabetIds[index]})`
: 'Display name'
}
defaultValue={(normalized as IChartEventItem & { type: 'event' }).displayName}
onChange={(e) => {
dispatchChangeEvent({
...(normalized as IChartEventItem & { type: 'event' }),
displayName: e.target.value,
});
}}
/>
)}
<ReportEventMore onClick={handleMore(normalized)} />
),
);
}}
items={eventNames}
placeholder="Select event"
/>
{showDisplayNameInput && (
<Input
placeholder={
event.name
? `${event.name} (${alphabetIds[index]})`
: 'Display name'
}
defaultValue={event.displayName}
onChange={(e) => {
dispatchChangeEvent({
...event,
displayName: e.target.value,
});
}}
/>
)}
<ReportEventMore onClick={handleMore(event)} />
</>
)}
</SortableEvent>
@@ -328,38 +324,40 @@ export function ReportEvents() {
})}
<div className="flex gap-2">
<ComboboxEvents
disabled={isAddEventDisabled}
value={''}
searchable
onChange={(value) => {
if (isSelectManyEvents) {
dispatch(
addEvent({
segment: 'user',
name: value,
filters: [
{
name: 'name',
operator: 'is',
value: [value],
},
],
}),
);
} else {
dispatch(
addEvent({
name: value,
segment: 'event',
filters: [],
}),
);
}
}}
placeholder="Select event"
items={eventNames}
/>
<ComboboxEvents
disabled={isAddEventDisabled}
value={''}
searchable
onChange={(value) => {
if (isSelectManyEvents) {
dispatch(
addSerie({
type: 'event',
segment: 'user',
name: value,
filters: [
{
name: 'name',
operator: 'is',
value: [value],
},
],
}),
);
} else {
dispatch(
addSerie({
type: 'event',
name: value,
segment: 'event',
filters: [],
}),
);
}
}}
placeholder="Select event"
items={eventNames}
/>
{showFormula && (
<Button
type="button"
@@ -367,7 +365,7 @@ export function ReportEvents() {
icon={PlusIcon}
onClick={() => {
dispatch(
addFormula({
addSerie({
type: 'formula',
formula: '',
displayName: '',

View File

@@ -30,11 +30,10 @@ import type {
IChartEventItem,
IChartFormula,
} from '@openpanel/validation';
import { FilterIcon, HandIcon, PiIcon, PlusIcon } from 'lucide-react';
import { FilterIcon, HandIcon, PiIcon } from 'lucide-react';
import { ReportSegment } from '../ReportSegment';
import {
addEvent,
addFormula,
addSerie,
changeEvent,
duplicateEvent,
removeEvent,
@@ -168,7 +167,7 @@ export function ReportSeries() {
const isAddEventDisabled =
(chartType === 'retention' || chartType === 'conversion') &&
selectedSeries.length >= 2;
const dispatchChangeEvent = useDebounceFn((event: IChartEvent) => {
const dispatchChangeEvent = useDebounceFn((event: IChartEventItem) => {
dispatch(changeEvent(event));
});
const isSelectManyEvents = chartType === 'retention';
@@ -361,7 +360,8 @@ export function ReportSeries() {
onChange={(value) => {
if (isSelectManyEvents) {
dispatch(
addEvent({
addSerie({
type: 'event',
segment: 'user',
name: value,
filters: [
@@ -375,7 +375,8 @@ export function ReportSeries() {
);
} else {
dispatch(
addEvent({
addSerie({
type: 'event',
name: value,
segment: 'event',
filters: [],
@@ -393,7 +394,7 @@ export function ReportSeries() {
icon={PiIcon}
onClick={() => {
dispatch(
addFormula({
addSerie({
type: 'formula',
formula: '',
displayName: '',

View File

@@ -4,7 +4,6 @@ import { useSelector } from '@/redux';
import { ReportBreakdowns } from './ReportBreakdowns';
import { ReportSeries } from './ReportSeries';
import { ReportFormula } from './ReportFormula';
import { ReportSettings } from './ReportSettings';
export function ReportSidebar() {

View File

@@ -39,14 +39,12 @@ interface PureFilterProps {
}
export function FilterItem({ filter, event }: FilterProps) {
// const { range, startDate, endDate, interval } = useSelector(
// (state) => state.report,
// );
const onRemove = ({ id }: IChartEventFilter) => {
dispatch(
changeEvent({
...event,
filters: event.filters.filter((item) => item.id !== id),
type: 'event',
}),
);
};
@@ -58,6 +56,7 @@ export function FilterItem({ filter, event }: FilterProps) {
dispatch(
changeEvent({
...event,
type: 'event',
filters: event.filters.map((item) => {
if (item.id === id) {
return {
@@ -79,6 +78,7 @@ export function FilterItem({ filter, event }: FilterProps) {
dispatch(
changeEvent({
...event,
type: 'event',
filters: event.filters.map((item) => {
if (item.id === id) {
return {

View File

@@ -56,9 +56,8 @@ export default function AddNotificationRule({ rule }: Props) {
template: rule?.template ?? '',
config: rule?.config ?? {
type: 'events',
series: [
events: [
{
type: 'event',
name: '',
segment: 'event',
filters: [],

View File

@@ -341,13 +341,14 @@ export default function EventDetails({ id, createdAt, projectId }: Props) {
<ReportChartShortcut
projectId={event.projectId}
chartType="linear"
events={[
series={[
{
id: 'A',
name: event.name,
displayName: 'Similar events',
segment: 'event',
filters: [],
type: 'event',
},
]}
/>

View File

@@ -9,7 +9,7 @@ import {
useEventQueryNamesFilter,
} from '@/hooks/use-event-query-filters';
import type { IChartEvent } from '@openpanel/validation';
import type { IChartEventItem } from '@openpanel/validation';
import { createFileRoute } from '@tanstack/react-router';
@@ -23,13 +23,14 @@ function Component() {
const { projectId } = Route.useParams();
const [filters] = useEventQueryFilters();
const [events] = useEventQueryNamesFilter();
const fallback: IChartEvent[] = [
const fallback: IChartEventItem[] = [
{
id: 'A',
name: '*',
displayName: 'All events',
segment: 'event',
filters: filters ?? [],
type: 'event',
},
];
@@ -49,7 +50,7 @@ function Component() {
projectId={projectId}
range="30d"
chartType="histogram"
events={
series={
events && events.length > 0
? events.map((name) => ({
id: name,
@@ -57,6 +58,7 @@ function Component() {
displayName: name,
segment: 'event',
filters: filters ?? [],
type: 'event',
}))
: fallback
}
@@ -78,7 +80,7 @@ function Component() {
name: 'name',
},
]}
events={
series={
events && events.length > 0
? events.map((name) => ({
id: name,
@@ -86,6 +88,7 @@ function Component() {
displayName: name,
segment: 'event',
filters: filters ?? [],
type: 'event',
}))
: [
{
@@ -94,6 +97,7 @@ function Component() {
displayName: 'All events',
segment: 'event',
filters: filters ?? [],
type: 'event',
},
]
}
@@ -115,7 +119,7 @@ function Component() {
name: 'name',
},
]}
events={
series={
events && events.length > 0
? events.map((name) => ({
id: name,
@@ -123,6 +127,7 @@ function Component() {
displayName: name,
segment: 'event',
filters: filters ?? [],
type: 'event',
}))
: [
{
@@ -131,6 +136,7 @@ function Component() {
displayName: 'All events',
segment: 'event',
filters: filters ?? [],
type: 'event',
},
]
}
@@ -152,7 +158,7 @@ function Component() {
name: 'name',
},
]}
events={
series={
events && events.length > 0
? events.map((name) => ({
id: name,
@@ -160,6 +166,7 @@ function Component() {
displayName: name,
segment: 'event',
filters: filters ?? [],
type: 'event',
}))
: [
{
@@ -168,6 +175,7 @@ function Component() {
displayName: 'All events',
segment: 'event',
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 { alphabetIds } from '@openpanel/constants';
import type {
IChartBreakdown,
IChartEvent,
IChartEventItem,
} from '@openpanel/validation';
import type { IChartEventItem } from '@openpanel/validation';
import { getSettingsForProject } from '../services/organization.service';
import type { ConcreteSeries, Plan } from './types';
import type { NormalizedInput } from './normalize';
import type { ConcreteSeries, Plan } from './types';
/**
* Create an execution plan from normalized input
* This sets up ConcreteSeries placeholders - actual breakdown expansion happens during fetch
*/
export async function plan(
normalized: NormalizedInput,
): Promise<Plan> {
export async function plan(normalized: NormalizedInput): Promise<Plan> {
const { timezone } = await getSettingsForProject(normalized.projectId);
const concreteSeries: ConcreteSeries[] = [];
@@ -24,7 +18,7 @@ export async function plan(
normalized.series.forEach((definition, index) => {
if (definition.type === 'event') {
const event = definition as IChartEventItem & { type: 'event' };
// For events, create a placeholder
// If breakdowns exist, fetch will return multiple series (one per breakdown value)
// If no breakdowns, fetch will return one series
@@ -54,6 +48,3 @@ export async function plan(
timezone,
};
}
export type NormalizedInput = Awaited<ReturnType<typeof import('./normalize').normalize>>;

View File

@@ -7,6 +7,7 @@ import {
getEventFiltersWhereClause,
getSelectPropertyKey,
} from './chart.service';
import { onlyReportEvents } from './reports.service';
export class ConversionService {
constructor(private client: typeof ch) {}
@@ -18,7 +19,6 @@ export class ConversionService {
funnelGroup,
funnelWindow = 24,
series,
events, // Backward compatibility - use series if available
breakdowns = [],
interval,
timezone,
@@ -31,12 +31,9 @@ export class ConversionService {
);
const breakdownGroupBy = breakdowns.map((b, index) => `b_${index}`);
// Use series if available, otherwise fall back to events (backward compat)
const eventSeries = (series ?? events ?? []).filter(
(item): item is IChartEvent => item.type === 'event',
) as IChartEvent[];
const events = onlyReportEvents(series);
if (eventSeries.length !== 2) {
if (events.length !== 2) {
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');
}
const eventA = eventSeries[0]!;
const eventB = eventSeries[1]!;
const eventA = events[0]!;
const eventB = events[1]!;
const whereA = Object.values(
getEventFiltersWhereClause(eventA.filters),
).join(' AND ');

View File

@@ -14,6 +14,7 @@ import {
getEventFiltersWhereClause,
getSelectPropertyKey,
} from './chart.service';
import { onlyReportEvents } from './reports.service';
export class FunnelService {
constructor(private client: typeof ch) {}
@@ -179,7 +180,6 @@ export class FunnelService {
startDate,
endDate,
series,
events, // Backward compatibility - use series if available
funnelWindow = 24,
funnelGroup,
breakdowns = [],
@@ -189,12 +189,7 @@ export class FunnelService {
throw new Error('startDate and endDate are required');
}
// Use series if available, otherwise fall back to events (backward compat)
const rawSeries = (series ?? events ?? []) as IChartEventItem[];
const eventSeries = rawSeries.filter(
(item): item is IChartEventItem & { type: 'event' } =>
item.type === 'event',
) as IChartEvent[];
const eventSeries = onlyReportEvents(series);
if (eventSeries.length === 0) {
throw new Error('events are required');

View File

@@ -5,21 +5,25 @@ import {
} from '@openpanel/constants';
import type {
IChartBreakdown,
IChartEvent,
IChartEventFilter,
IChartEventItem,
IChartFormula,
IChartLineType,
IChartProps,
IChartRange,
ICriteria,
} from '@openpanel/validation';
import { db } 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 const onlyReportEvents = (
series: NonNullable<IServiceReport>['series'],
) => {
return series.filter((item) => item.type === 'event');
};
export function transformFilter(
filter: Partial<IChartEventFilter>,
index: number,
@@ -34,72 +38,39 @@ export function transformFilter(
}
export function transformReportEventItem(
item: Partial<IChartEventItem> | Partial<IChartEvent>,
item: IChartEventItem,
index: number,
): IChartEventItem {
// If item already has type field, it's the new format
if (item && typeof item === 'object' && 'type' in item) {
if (item.type === 'formula') {
// Transform formula
const formula = item as Partial<IChartFormula>;
return {
type: 'formula',
id: formula.id ?? alphabetIds[index]!,
formula: formula.formula || '',
displayName: formula.displayName,
};
}
// Transform event with type field
const event = item as Partial<IChartEvent>;
if (item.type === 'formula') {
// Transform formula
return {
type: 'event',
segment: event.segment ?? 'event',
filters: (event.filters ?? []).map(transformFilter),
id: event.id ?? alphabetIds[index]!,
name: event.name || 'unknown_event',
displayName: event.displayName,
property: event.property,
type: 'formula',
id: item.id ?? alphabetIds[index]!,
formula: item.formula || '',
displayName: item.displayName,
};
}
// Old format without type field - assume it's an event
const event = item as Partial<IChartEvent>;
// Transform event with type field
return {
type: 'event',
segment: event.segment ?? 'event',
filters: (event.filters ?? []).map(transformFilter),
id: event.id ?? alphabetIds[index]!,
name: event.name || 'unknown_event',
displayName: event.displayName,
property: event.property,
segment: item.segment ?? 'event',
filters: (item.filters ?? []).map(transformFilter),
id: item.id ?? alphabetIds[index]!,
name: item.name || 'unknown_event',
displayName: item.displayName,
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(
report: DbReport & { layout?: ReportLayout | null },
): IChartProps & { id: string; layout?: ReportLayout | null } {
// Events can be either old format (IChartEvent[]) or new format (IChartEventItem[])
const eventsData = report.events as unknown as Array<
Partial<IChartEventItem> | Partial<IChartEvent>
>;
return {
id: report.id,
projectId: report.projectId,
series: eventsData.map(transformReportEventItem),
series:
(report.events as IChartEventItem[]).map(transformReportEventItem) ?? [],
breakdowns: report.breakdowns as IChartBreakdown[],
chartType: report.chartType,
lineType: (report.lineType as IChartLineType) ?? lineTypes.monotone,

View File

@@ -21,6 +21,7 @@ import {
getProfilesCached,
getSelectPropertyKey,
getSettingsForProject,
onlyReportEvents,
} from '@openpanel/db';
import {
type IChartEvent,
@@ -611,9 +612,7 @@ export const chartRouter = createTRPCRouter({
// });
// Get unique profile IDs
console.log('profileIdsQuery', getSql());
const profileIds = await chQuery<{ profile_id: string }>(getSql());
console.log('profileIds', profileIds.length);
if (profileIds.length === 0) {
return [];
}
@@ -663,10 +662,7 @@ export const chartRouter = createTRPCRouter({
// stepIndex is 0-based, but level is 1-based, so we need level >= stepIndex + 1
const targetLevel = stepIndex + 1;
const eventSeries = series.filter(
(item): item is typeof item & { type: 'event' } =>
item.type === 'event',
);
const eventSeries = onlyReportEvents(series);
if (eventSeries.length === 0) {
throw new Error('At least one event series is required');

View File

@@ -1,5 +1,9 @@
import type { z } from 'zod';
export type UnionOmit<T, K extends keyof any> = T extends any
? Omit<T, K>
: never;
import type {
zChartBreakdown,
zChartEvent,