This commit is contained in:
Carl-Gerhard Lindesvärd
2025-11-21 11:21:17 +01:00
parent dd71fd4e11
commit 06fb6c4f3c
40 changed files with 2944 additions and 1972 deletions

View File

@@ -103,7 +103,6 @@
"lodash.throttle": "^4.1.1",
"lottie-react": "^2.4.0",
"lucide-react": "^0.476.0",
"mathjs": "^12.3.2",
"mitt": "^3.0.1",
"nuqs": "^2.5.2",
"prisma-error-enum": "^0.1.3",

View File

@@ -17,10 +17,10 @@ export function ReportChartEmpty({
}) {
const {
isEditMode,
report: { events },
report: { series },
} = useReportChartContext();
if (events.length === 0) {
if (!series || series.length === 0) {
return (
<div className="card p-4 center-center h-full w-full flex-col relative">
<div className="row gap-2 items-end absolute top-4 left-4">

View File

@@ -0,0 +1,46 @@
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { List, Rows3, Search, X } from 'lucide-react';
interface ReportTableToolbarProps {
grouped: boolean;
onToggleGrouped: () => void;
search: string;
onSearchChange: (value: string) => void;
onUnselectAll: () => void;
}
export function ReportTableToolbar({
grouped,
onToggleGrouped,
search,
onSearchChange,
onUnselectAll,
}: ReportTableToolbarProps) {
return (
<div className="col md:row md:items-center gap-2 p-2 border-b md:justify-between">
<div className="relative flex-1 w-full md:max-w-sm">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
placeholder="Search..."
value={search}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-8"
/>
</div>
<div className="flex items-center gap-2">
<Button
variant={'outline'}
size="sm"
onClick={onToggleGrouped}
icon={grouped ? Rows3 : List}
>
{grouped ? 'Grouped' : 'Flat'}
</Button>
<Button variant="outline" size="sm" onClick={onUnselectAll} icon={X}>
Unselect All
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,325 @@
import { getPropertyLabel } from '@/translations/properties';
import type { IChartData } from '@/trpc/client';
export type TableRow = {
id: string;
serieName: string;
breakdownValues: string[];
sum: number;
average: number;
min: number;
max: number;
dateValues: Record<string, number>; // date -> count
originalSerie: IChartData['series'][0];
// Group metadata for collapse functionality
groupKey?: string; // Unique key for the group this row belongs to
parentGroupKey?: string; // Key of parent group (for nested groups)
isSummaryRow?: boolean; // True if this is a summary row for a collapsed group
};
export type GroupedTableRow = TableRow & {
// For grouped mode, indicates which breakdown levels should show empty cells
breakdownDisplay: (string | null)[]; // null means show empty cell
};
/**
* Extract unique dates from all series
*/
function getUniqueDates(series: IChartData['series']): string[] {
const dateSet = new Set<string>();
series.forEach((serie) => {
serie.data.forEach((d) => {
dateSet.add(d.date);
});
});
return Array.from(dateSet).sort();
}
/**
* Get breakdown property names from series
* Breakdown values are in names.slice(1), so we need to infer the property names
* from the breakdowns array or from the series structure
*/
function getBreakdownPropertyNames(
series: IChartData['series'],
breakdowns: Array<{ name: string }>,
): string[] {
// If we have breakdowns from state, use those
if (breakdowns.length > 0) {
return breakdowns.map((b) => getPropertyLabel(b.name));
}
// Otherwise, infer from series names
// All series should have the same number of breakdown values
if (series.length === 0) return [];
const firstSerie = series[0];
const breakdownCount = firstSerie.names.length - 1;
return Array.from({ length: breakdownCount }, (_, i) => `Breakdown ${i + 1}`);
}
/**
* Transform series into flat table rows
*/
export function createFlatRows(
series: IChartData['series'],
dates: string[],
): TableRow[] {
return series.map((serie) => {
const dateValues: Record<string, number> = {};
dates.forEach((date) => {
const dataPoint = serie.data.find((d) => d.date === date);
dateValues[date] = dataPoint?.count ?? 0;
});
return {
id: serie.id,
serieName: serie.names[0] ?? '',
breakdownValues: serie.names.slice(1),
sum: serie.metrics.sum,
average: serie.metrics.average,
min: serie.metrics.min,
max: serie.metrics.max,
dateValues,
originalSerie: serie,
};
});
}
/**
* Transform series into grouped table rows
* Groups rows hierarchically by breakdown values
*/
export function createGroupedRows(
series: IChartData['series'],
dates: string[],
): GroupedTableRow[] {
const flatRows = createFlatRows(series, dates);
// Sort by sum descending
flatRows.sort((a, b) => b.sum - a.sum);
// Group rows by breakdown values hierarchically
const grouped: GroupedTableRow[] = [];
const breakdownCount = flatRows[0]?.breakdownValues.length ?? 0;
if (breakdownCount === 0) {
// No breakdowns, just return flat rows
return flatRows.map((row) => ({
...row,
breakdownDisplay: [],
}));
}
// Group rows hierarchically by breakdown values
// We need to group by parent breakdowns first, then by child breakdowns
// This creates the nested structure shown in the user's example
// First, group by first breakdown value
const groupsByFirstBreakdown = new Map<string, TableRow[]>();
flatRows.forEach((row) => {
const firstBreakdown = row.breakdownValues[0] ?? '';
if (!groupsByFirstBreakdown.has(firstBreakdown)) {
groupsByFirstBreakdown.set(firstBreakdown, []);
}
groupsByFirstBreakdown.get(firstBreakdown)!.push(row);
});
// Sort groups by sum of highest row in group
const sortedGroups = Array.from(groupsByFirstBreakdown.entries()).sort(
(a, b) => {
const aMax = Math.max(...a[1].map((r) => r.sum));
const bMax = Math.max(...b[1].map((r) => r.sum));
return bMax - aMax;
},
);
// Process each group hierarchically
sortedGroups.forEach(([firstBreakdownValue, groupRows]) => {
// Within each first-breakdown group, sort by sum
groupRows.sort((a, b) => b.sum - a.sum);
// Generate group key for this first-breakdown group
const groupKey = firstBreakdownValue;
// For each row in the group
groupRows.forEach((row, index) => {
const breakdownDisplay: (string | null)[] = [];
if (index === 0) {
// First row shows all breakdown values
breakdownDisplay.push(...row.breakdownValues);
} else {
// Subsequent rows: compare with first row in group
const firstRow = groupRows[0]!;
for (let i = 0; i < row.breakdownValues.length; i++) {
// Show empty if this breakdown matches the first row at this position
if (i < firstRow.breakdownValues.length) {
if (row.breakdownValues[i] === firstRow.breakdownValues[i]) {
breakdownDisplay.push(null);
} else {
breakdownDisplay.push(row.breakdownValues[i]!);
}
} else {
breakdownDisplay.push(row.breakdownValues[i]!);
}
}
}
grouped.push({
...row,
breakdownDisplay,
groupKey,
});
});
});
return grouped;
}
/**
* Create a summary row for a collapsed group
*/
export function createSummaryRow(
groupRows: TableRow[],
groupKey: string,
breakdownCount: number,
): GroupedTableRow {
// Aggregate metrics from all rows in the group
const totalSum = groupRows.reduce((sum, row) => sum + row.sum, 0);
const totalAverage =
groupRows.reduce((sum, row) => sum + row.average, 0) / groupRows.length;
const totalMin = Math.min(...groupRows.map((row) => row.min));
const totalMax = Math.max(...groupRows.map((row) => row.max));
// Aggregate date values
const dateValues: Record<string, number> = {};
const allDates = new Set<string>();
groupRows.forEach((row) => {
Object.keys(row.dateValues).forEach((date) => {
allDates.add(date);
dateValues[date] = (dateValues[date] ?? 0) + row.dateValues[date];
});
});
// Get breakdown values from first row
const firstRow = groupRows[0]!;
const breakdownDisplay: (string | null)[] = [];
breakdownDisplay.push(firstRow.breakdownValues[0] ?? null);
// Fill remaining breakdowns with null (empty)
for (let i = 1; i < breakdownCount; i++) {
breakdownDisplay.push(null);
}
return {
id: `summary-${groupKey}`,
serieName: firstRow.serieName,
breakdownValues: firstRow.breakdownValues,
sum: totalSum,
average: totalAverage,
min: totalMin,
max: totalMax,
dateValues,
originalSerie: firstRow.originalSerie,
groupKey,
isSummaryRow: true,
breakdownDisplay,
};
}
/**
* Reorder breakdowns by number of unique values (fewest first)
*/
function reorderBreakdownsByUniqueCount(
series: IChartData['series'],
breakdownPropertyNames: string[],
): {
reorderedNames: string[];
reorderMap: number[]; // Maps new index -> old index
reverseMap: number[]; // Maps old index -> new index
} {
if (breakdownPropertyNames.length === 0 || series.length === 0) {
return {
reorderedNames: breakdownPropertyNames,
reorderMap: [],
reverseMap: [],
};
}
// Count unique values for each breakdown index
const uniqueCounts = breakdownPropertyNames.map((_, index) => {
const uniqueValues = new Set<string>();
series.forEach((serie) => {
const value = serie.names[index + 1]; // +1 because names[0] is serie name
if (value) {
uniqueValues.add(value);
}
});
return { index, count: uniqueValues.size };
});
// Sort by count (ascending - fewest first)
uniqueCounts.sort((a, b) => a.count - b.count);
// Create reordered names and mapping
const reorderedNames = uniqueCounts.map(
(item) => breakdownPropertyNames[item.index]!,
);
const reorderMap = uniqueCounts.map((item) => item.index); // new index -> old index
const reverseMap = new Array(breakdownPropertyNames.length);
reorderMap.forEach((oldIndex, newIndex) => {
reverseMap[oldIndex] = newIndex;
});
return { reorderedNames, reorderMap, reverseMap };
}
/**
* Transform chart data into table-ready format
*/
export function transformToTableData(
data: IChartData,
breakdowns: Array<{ name: string }>,
grouped: boolean,
): {
rows: TableRow[] | GroupedTableRow[];
dates: string[];
breakdownPropertyNames: string[];
} {
const dates = getUniqueDates(data.series);
const originalBreakdownPropertyNames = getBreakdownPropertyNames(
data.series,
breakdowns,
);
// Reorder breakdowns by unique count (fewest first)
const { reorderedNames: breakdownPropertyNames, reorderMap } =
reorderBreakdownsByUniqueCount(data.series, originalBreakdownPropertyNames);
// Reorder breakdown values in series before creating rows
const reorderedSeries = data.series.map((serie) => {
const reorderedNames = [
serie.names[0], // Keep serie name first
...reorderMap.map((oldIndex) => serie.names[oldIndex + 1] ?? ''), // Reorder breakdown values
];
return {
...serie,
names: reorderedNames,
};
});
const rows = grouped
? createGroupedRows(reorderedSeries, dates)
: createFlatRows(reorderedSeries, dates);
// Sort flat rows by sum descending
if (!grouped) {
(rows as TableRow[]).sort((a, b) => b.sum - a.sum);
}
return {
rows,
dates,
breakdownPropertyNames,
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -144,14 +144,16 @@ export function Summary({ data }: Props) {
title="Flow"
value={
<div className="row flex-wrap gap-1">
{report.events.map((event, index) => {
return (
<div key={event.id} className="row items-center gap-2">
{index !== 0 && <ChevronRightIcon className="size-3" />}
<span>{event.name}</span>
</div>
);
})}
{report.series
.filter((item) => item.type === 'event')
.map((event, index) => {
return (
<div key={event.id} className="row items-center gap-2">
{index !== 0 && <ChevronRightIcon className="size-3" />}
<span>{event.name}</span>
</div>
);
})}
</div>
}
/>

View File

@@ -14,7 +14,7 @@ import { Chart, Summary, Tables } from './chart';
export function ReportFunnelChart() {
const {
report: {
events,
series,
range,
projectId,
funnelWindow,
@@ -28,7 +28,7 @@ export function ReportFunnelChart() {
} = useReportChartContext();
const input: IChartInput = {
events,
series,
range,
projectId,
interval: 'day',
@@ -44,7 +44,7 @@ export function ReportFunnelChart() {
const trpc = useTRPC();
const res = useQuery(
trpc.chart.funnel.queryOptions(input, {
enabled: !isLazyLoading && input.events.length > 0,
enabled: !isLazyLoading && input.series.length > 0,
}),
);

View File

@@ -1,3 +1,9 @@
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useRechartDataModel } from '@/hooks/use-rechart-data-model';
import { useVisibleSeries } from '@/hooks/use-visible-series';
import { useTRPC } from '@/integrations/trpc/react';
@@ -5,17 +11,12 @@ import { pushModal } from '@/modals';
import type { IChartData } from '@/trpc/client';
import { cn } from '@/utils/cn';
import { getChartColor } from '@/utils/theme';
import type { IChartEvent } from '@openpanel/validation';
import { useQuery } from '@tanstack/react-query';
import { isSameDay, isSameHour, isSameMonth, isSameWeek } from 'date-fns';
import { BookmarkIcon, UsersIcon } from 'lucide-react';
import { last } from 'ramda';
import { useCallback, useState } from 'react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { UsersIcon, BookmarkIcon } from 'lucide-react';
import {
CartesianGrid,
ComposedChart,
@@ -51,14 +52,20 @@ export function Chart({ data }: Props) {
endDate,
range,
lineType,
events,
series: reportSeries,
breakdowns,
},
isEditMode,
options: { hideXAxis, hideYAxis, maxDomain },
} = useReportChartContext();
const [clickPosition, setClickPosition] = useState<{ x: number; y: number } | null>(null);
const [clickedData, setClickedData] = useState<{ date: string; serieId?: string } | null>(null);
const [clickPosition, setClickPosition] = useState<{
x: number;
y: number;
} | null>(null);
const [clickedData, setClickedData] = useState<{
date: string;
serieId?: string;
} | null>(null);
const dataLength = data.series[0]?.data?.length || 0;
const trpc = useTRPC();
const references = useQuery(
@@ -144,9 +151,19 @@ export function Chart({ data }: Props) {
const payload = e.activePayload[0].payload;
const activeCoordinate = e.activeCoordinate;
if (payload.date) {
// Find the first valid serie ID from activePayload (skip calcStrokeDasharray)
const validPayload = e.activePayload.find(
(p: any) =>
p.dataKey &&
p.dataKey !== 'calcStrokeDasharray' &&
typeof p.dataKey === 'string' &&
p.dataKey.includes(':count'),
);
const serieId = validPayload?.dataKey?.toString().replace(':count', '');
setClickedData({
date: payload.date,
serieId: e.activePayload[0].dataKey?.toString().replace(':count', ''),
serieId,
});
setClickPosition({
x: activeCoordinate?.x ?? 0,
@@ -157,36 +174,39 @@ export function Chart({ data }: Props) {
}, []);
const handleViewUsers = useCallback(() => {
if (!clickedData || !projectId || !startDate || !endDate) return;
// Find the event for the clicked serie
const serie = series.find((s) => s.id === clickedData.serieId);
const event = events.find((e) => {
const normalized = 'type' in e ? e : { ...e, type: 'event' as const };
if (normalized.type === 'event') {
return serie?.event.id === normalized.id || serie?.event.name === normalized.name;
}
return false;
});
if (!clickedData || !projectId) return;
if (event) {
const normalized = 'type' in event ? event : { ...event, type: 'event' as const };
if (normalized.type === 'event') {
pushModal('ViewChartUsers', {
projectId,
event: normalized,
date: clickedData.date,
breakdowns: breakdowns || [],
interval,
startDate,
endDate,
filters: normalized.filters || [],
});
}
}
// Pass the chart data (which we already have) and the report config
pushModal('ViewChartUsers', {
chartData: data,
report: {
projectId,
series: reportSeries,
breakdowns: breakdowns || [],
interval,
startDate,
endDate,
range,
previous,
chartType: 'linear',
metric: 'sum',
},
date: clickedData.date,
});
setClickPosition(null);
setClickedData(null);
}, [clickedData, projectId, startDate, endDate, events, series, breakdowns, interval]);
}, [
clickedData,
projectId,
data,
reportSeries,
breakdowns,
interval,
startDate,
endDate,
range,
previous,
]);
const handleAddReference = useCallback(() => {
if (!clickedData) return;

View File

@@ -12,7 +12,7 @@ import CohortTable from './table';
export function ReportRetentionChart() {
const {
report: {
events,
series,
range,
projectId,
startDate,
@@ -22,8 +22,9 @@ export function ReportRetentionChart() {
},
isLazyLoading,
} = useReportChartContext();
const firstEvent = (events[0]?.filters[0]?.value ?? []).map(String);
const secondEvent = (events[1]?.filters[0]?.value ?? []).map(String);
const eventSeries = series.filter((item) => item.type === 'event');
const firstEvent = (eventSeries[0]?.filters?.[0]?.value ?? []).map(String);
const secondEvent = (eventSeries[1]?.filters?.[0]?.value ?? []).map(String);
const isEnabled =
firstEvent.length > 0 && secondEvent.length > 0 && !isLazyLoading;
const trpc = useTRPC();

View File

@@ -41,7 +41,7 @@ const initialState: InitialState = {
lineType: 'monotone',
interval: 'day',
breakdowns: [],
events: [],
series: [],
range: '30d',
startDate: null,
endDate: null,
@@ -88,10 +88,10 @@ export const reportSlice = createSlice({
state.dirty = true;
state.name = action.payload;
},
// Events and Formulas
// Series (Events and Formulas)
addEvent: (state, action: PayloadAction<Omit<IChartEvent, 'id'>>) => {
state.dirty = true;
state.events.push({
state.series.push({
id: shortId(),
type: 'event',
...action.payload,
@@ -102,7 +102,7 @@ export const reportSlice = createSlice({
action: PayloadAction<Omit<IChartFormula, 'id'>>,
) => {
state.dirty = true;
state.events.push({
state.series.push({
id: shortId(),
...action.payload,
} as IChartEventItem);
@@ -113,16 +113,16 @@ export const reportSlice = createSlice({
) => {
state.dirty = true;
if (action.payload.type === 'event') {
state.events.push({
...action.payload,
filters: action.payload.filters.map((filter) => ({
...filter,
id: shortId(),
})),
state.series.push({
...action.payload,
filters: action.payload.filters.map((filter) => ({
...filter,
id: shortId(),
})),
id: shortId(),
} as IChartEventItem);
} else {
state.events.push({
state.series.push({
...action.payload,
id: shortId(),
} as IChartEventItem);
@@ -135,7 +135,7 @@ export const reportSlice = createSlice({
}>,
) => {
state.dirty = true;
state.events = state.events.filter(
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;
@@ -145,7 +145,7 @@ export const reportSlice = createSlice({
},
changeEvent: (state, action: PayloadAction<IChartEventItem>) => {
state.dirty = true;
state.events = state.events.map((event) => {
state.series = state.series.map((event) => {
const eventId = 'type' in event ? event.id : (event as IChartEvent).id;
if (eventId === action.payload.id) {
return action.payload;
@@ -293,9 +293,9 @@ export const reportSlice = createSlice({
) {
state.dirty = true;
const { fromIndex, toIndex } = action.payload;
const [movedEvent] = state.events.splice(fromIndex, 1);
const [movedEvent] = state.series.splice(fromIndex, 1);
if (movedEvent) {
state.events.splice(toIndex, 0, movedEvent);
state.series.splice(toIndex, 0, movedEvent);
}
},
},

View File

@@ -266,60 +266,60 @@ export function ReportEvents() {
</>
) : (
<>
<ComboboxEvents
className="flex-1"
searchable
multiple={isSelectManyEvents as false}
value={
(isSelectManyEvents
<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)
? {
}
onChange={(value) => {
dispatch(
changeEvent(
Array.isArray(value)
? {
id: normalized.id,
type: 'event',
segment: 'user',
filters: [
{
name: 'name',
operator: 'is',
value: value,
},
],
name: '*',
}
: {
segment: 'user',
filters: [
{
name: 'name',
operator: 'is',
value: value,
},
],
name: '*',
}
: {
...normalized,
type: 'event',
name: value,
filters: [],
},
),
);
}}
items={eventNames}
placeholder="Select event"
/>
{showDisplayNameInput && (
<Input
placeholder={
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'
}
: 'Display name'
}
defaultValue={(normalized as IChartEventItem & { type: 'event' }).displayName}
onChange={(e) => {
dispatchChangeEvent({
onChange={(e) => {
dispatchChangeEvent({
...(normalized as IChartEventItem & { type: 'event' }),
displayName: e.target.value,
});
}}
/>
)}
displayName: e.target.value,
});
}}
/>
)}
<ReportEventMore onClick={handleMore(normalized)} />
</>
)}
@@ -328,38 +328,38 @@ 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(
addEvent({
segment: 'user',
name: value,
filters: [
{
name: 'name',
operator: 'is',
value: [value],
},
],
}),
);
} else {
dispatch(
addEvent({
name: value,
segment: 'event',
filters: [],
}),
);
}
}}
placeholder="Select event"
items={eventNames}
/>
{showFormula && (
<Button
type="button"

View File

@@ -1,24 +0,0 @@
import { useDispatch, useSelector } from '@/redux';
import { InputEnter } from '@/components/ui/input-enter';
import { changeFormula } from '../reportSlice';
export function ReportFormula() {
const formula = useSelector((state) => state.report.formula);
const dispatch = useDispatch();
return (
<div>
<h3 className="mb-2 font-medium">Formula</h3>
<div className="flex flex-col gap-4">
<InputEnter
placeholder="eg: A/B"
value={formula ?? ''}
onChangeValue={(value) => {
dispatch(changeFormula(value));
}}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,413 @@
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';
import { useDispatch, useSelector } from '@/redux';
import {
DndContext,
type DragEndEvent,
KeyboardSensor,
PointerSensor,
closestCenter,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
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 { FilterIcon, HandIcon, PiIcon, PlusIcon } from 'lucide-react';
import { ReportSegment } from '../ReportSegment';
import {
addEvent,
addFormula,
changeEvent,
duplicateEvent,
removeEvent,
reorderEvents,
} from '../reportSlice';
import { EventPropertiesCombobox } from './EventPropertiesCombobox';
import { PropertiesCombobox } from './PropertiesCombobox';
import type { ReportEventMoreProps } from './ReportEventMore';
import { ReportEventMore } from './ReportEventMore';
import { FiltersList } from './filters/FiltersList';
function SortableSeries({
event,
index,
showSegment,
showAddFilter,
isSelectManyEvents,
...props
}: {
event: IChartEventItem | IChartEvent;
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 ?? '' });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
// Normalize event to have type field
const normalizedEvent: IChartEventItem =
'type' in event ? event : { ...event, type: 'event' as const };
const isFormula = normalizedEvent.type === 'formula';
const chartEvent = isFormula
? null
: (normalizedEvent as IChartEventItem & { type: 'event' });
return (
<div ref={setNodeRef} style={style} {...attributes} {...props}>
<div className="flex items-center gap-2 p-2 group">
<button className="cursor-grab active:cursor-grabbing" {...listeners}>
<ColorSquare className="relative">
<HandIcon className="size-3 opacity-0 scale-50 group-hover:opacity-100 group-hover:scale-100 transition-all absolute inset-1" />
<span className="block group-hover:opacity-0 group-hover:scale-0 transition-all">
{alphabetIds[index]}
</span>
</ColorSquare>
</button>
{props.children}
</div>
{/* Segment and Filter buttons - only for events */}
{chartEvent && (showSegment || showAddFilter) && (
<div className="flex gap-2 p-2 pt-0">
{showSegment && (
<ReportSegment
value={chartEvent.segment}
onChange={(segment) => {
dispatch(
changeEvent({
...chartEvent,
segment,
}),
);
}}
/>
)}
{showAddFilter && (
<PropertiesCombobox
event={chartEvent}
onSelect={(action) => {
dispatch(
changeEvent({
...chartEvent,
filters: [
...chartEvent.filters,
{
id: shortId(),
name: action.value,
operator: 'is',
value: [],
},
],
}),
);
}}
>
{(setOpen) => (
<button
onClick={() => setOpen((p) => !p)}
type="button"
className="flex items-center gap-1 rounded-md border border-border bg-card p-1 px-2 text-sm font-medium leading-none"
>
<FilterIcon size={12} /> Add filter
</button>
)}
</PropertiesCombobox>
)}
{showSegment && chartEvent.segment.startsWith('property_') && (
<EventPropertiesCombobox event={chartEvent} />
)}
</div>
)}
{/* Filters - only for events */}
{chartEvent && !isSelectManyEvents && <FiltersList event={chartEvent} />}
</div>
);
}
export function ReportSeries() {
const selectedSeries = useSelector((state) => state.report.series);
const chartType = useSelector((state) => state.report.chartType);
const dispatch = useDispatch();
const { projectId } = useAppParams();
const eventNames = useEventNames({
projectId,
});
const showSegment = !['retention', 'funnel'].includes(chartType);
const showAddFilter = !['retention'].includes(chartType);
const showDisplayNameInput = !['retention'].includes(chartType);
const isAddEventDisabled =
(chartType === 'retention' || chartType === 'conversion') &&
selectedSeries.length >= 2;
const dispatchChangeEvent = useDebounceFn((event: IChartEvent) => {
dispatch(changeEvent(event));
});
const isSelectManyEvents = chartType === 'retention';
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (over && active.id !== over.id) {
const oldIndex = selectedSeries.findIndex((e) => e.id === active.id);
const newIndex = selectedSeries.findIndex((e) => e.id === over.id);
dispatch(reorderEvents({ fromIndex: oldIndex, toIndex: newIndex }));
}
};
const handleMore = (event: IChartEventItem | IChartEvent) => {
const callback: ReportEventMoreProps['onClick'] = (action) => {
switch (action) {
case 'remove': {
return dispatch(
removeEvent({
id: 'type' in event ? event.id : (event as IChartEvent).id,
}),
);
}
case 'duplicate': {
const normalized =
'type' in event ? event : { ...event, type: 'event' as const };
return dispatch(duplicateEvent(normalized));
}
}
};
return callback;
};
const dispatchChangeFormula = useDebounceFn((formula: IChartFormula) => {
dispatch(changeEvent(formula));
});
const showFormula =
chartType !== 'conversion' &&
chartType !== 'funnel' &&
chartType !== 'retention';
return (
<div>
<h3 className="mb-2 font-medium">Metrics</h3>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={selectedSeries.map((e) => ({
id: ('type' in e ? e.id : (e as IChartEvent).id) ?? '',
}))}
strategy={verticalListSortingStrategy}
>
<div className="flex flex-col gap-4">
{selectedSeries.map((event, index) => {
const isFormula = event.type === 'formula';
return (
<SortableSeries
key={event.id}
event={event}
index={index}
showSegment={showSegment}
showAddFilter={showAddFilter}
isSelectManyEvents={isSelectManyEvents}
className="rounded-lg border bg-def-100"
>
{isFormula ? (
<>
<div className="flex-1 flex flex-col gap-2">
<InputEnter
placeholder="eg: A+B"
value={event.formula}
onChangeValue={(value) => {
dispatchChangeFormula({
...event,
formula: value,
});
}}
/>
{showDisplayNameInput && (
<Input
placeholder={`Name: Formula (${alphabetIds[index]})`}
defaultValue={event.displayName}
onChange={(e) => {
dispatchChangeFormula({
...event,
displayName: e.target.value,
});
}}
/>
)}
</div>
<ReportEventMore onClick={handleMore(event)} />
</>
) : (
<>
<ComboboxEvents
className="flex-1"
searchable
multiple={isSelectManyEvents as false}
value={
(isSelectManyEvents
? ((
event as IChartEventItem & {
type: 'event';
}
).filters[0]?.value ?? [])
: (
event as IChartEventItem & {
type: '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,
},
],
name: '*',
}
: {
...event,
type: 'event',
name: value,
filters: [],
},
),
);
}}
items={eventNames}
placeholder="Select event"
/>
{showDisplayNameInput && (
<Input
placeholder={
(event as IChartEventItem & { type: 'event' }).name
? `${(event as IChartEventItem & { type: 'event' }).name} (${alphabetIds[index]})`
: 'Display name'
}
defaultValue={
(event as IChartEventItem & { type: 'event' })
.displayName
}
onChange={(e) => {
dispatchChangeEvent({
...(event as IChartEventItem & {
type: 'event';
}),
displayName: e.target.value,
});
}}
/>
)}
<ReportEventMore onClick={handleMore(event)} />
</>
)}
</SortableSeries>
);
})}
<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}
/>
{showFormula && (
<Button
type="button"
variant="outline"
icon={PiIcon}
onClick={() => {
dispatch(
addFormula({
type: 'formula',
formula: '',
displayName: '',
}),
);
}}
>
Add Formula
</Button>
)}
</div>
</div>
</SortableContext>
</DndContext>
</div>
);
}

View File

@@ -3,7 +3,7 @@ import { SheetClose, SheetFooter } from '@/components/ui/sheet';
import { useSelector } from '@/redux';
import { ReportBreakdowns } from './ReportBreakdowns';
import { ReportEvents } from './ReportEvents';
import { ReportSeries } from './ReportSeries';
import { ReportFormula } from './ReportFormula';
import { ReportSettings } from './ReportSettings';
@@ -13,7 +13,7 @@ export function ReportSidebar() {
return (
<>
<div className="flex flex-col gap-8">
<ReportEvents />
<ReportSeries />
{showBreakdown && <ReportBreakdowns />}
<ReportSettings />
</div>

View File

@@ -4,7 +4,7 @@ import { AnimatePresence } from 'framer-motion';
import { RefreshCcwIcon } from 'lucide-react';
import { type InputHTMLAttributes, useEffect, useState } from 'react';
import { Badge } from './badge';
import { Input } from './input';
import { Input, type InputProps } from './input';
export function InputEnter({
value,
@@ -13,7 +13,7 @@ export function InputEnter({
}: {
value: string | undefined;
onChangeValue: (value: string) => void;
} & InputHTMLAttributes<HTMLInputElement>) {
} & InputProps) {
const [internalValue, setInternalValue] = useState(value ?? '');
useEffect(() => {
@@ -33,7 +33,6 @@ export function InputEnter({
onChangeValue(internalValue);
}
}}
size="default"
/>
<div className="absolute right-2 top-1/2 -translate-y-1/2">
<AnimatePresence>

View File

@@ -1,62 +1,197 @@
import { ButtonContainer } from '@/components/button-container';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useTRPC } from '@/integrations/trpc/react';
import type { IChartData } from '@/trpc/client';
import type { IChartEvent, IChartInput } from '@openpanel/validation';
import { useQuery } from '@tanstack/react-query';
import { UsersIcon } from 'lucide-react';
import { useMemo, useState } from 'react';
import { popModal } from '.';
import { ModalContent, ModalHeader } from './Modal/Container';
import type { IChartEvent } from '@openpanel/validation';
interface ViewChartUsersProps {
projectId: string;
event: IChartEvent;
chartData: IChartData;
report: IChartInput;
date: string;
breakdowns?: Array<{ id?: string; name: string }>;
interval: string;
startDate: string;
endDate: string;
filters?: Array<{
id?: string;
name: string;
operator: string;
value: Array<string | number | boolean | null>;
}>;
}
export default function ViewChartUsers({
projectId,
event,
chartData,
report,
date,
breakdowns = [],
interval,
startDate,
endDate,
filters = [],
}: ViewChartUsersProps) {
const trpc = useTRPC();
const query = useQuery(
trpc.chart.getProfiles.queryOptions({
projectId,
event,
date,
breakdowns,
interval: interval as any,
startDate,
endDate,
filters,
}),
// Group series by base event/formula (ignoring breakdowns)
const baseSeries = useMemo(() => {
const grouped = new Map<
string,
{
baseName: string;
baseEventId: string;
reportSerie: IChartInput['series'][0] | undefined;
breakdownSeries: Array<{
serie: IChartData['series'][0];
breakdowns: Record<string, string> | undefined;
}>;
}
>();
chartData.series.forEach((serie) => {
const baseEventId = serie.event.id || '';
const baseName = serie.names[0] || 'Unnamed Serie';
if (!grouped.has(baseEventId)) {
const reportSerie = report.series.find((ss) => ss.id === baseEventId);
grouped.set(baseEventId, {
baseName,
baseEventId,
reportSerie,
breakdownSeries: [],
});
}
const group = grouped.get(baseEventId);
if (!group) return;
// Extract breakdowns from serie.event.breakdowns (set in format.ts)
const breakdowns = (serie.event as any).breakdowns;
group.breakdownSeries.push({
serie,
breakdowns,
});
});
return Array.from(grouped.values());
}, [chartData.series, report.series, report.breakdowns]);
const [selectedBaseSerieId, setSelectedBaseSerieId] = useState<string | null>(
null,
);
const [selectedBreakdownIndex, setSelectedBreakdownIndex] = useState<
number | null
>(null);
const selectedBaseSerie = useMemo(
() => baseSeries.find((bs) => bs.baseEventId === selectedBaseSerieId),
[baseSeries, selectedBaseSerieId],
);
const profiles = query.data ?? [];
const selectedBreakdown = useMemo(() => {
if (
!selectedBaseSerie ||
selectedBreakdownIndex === null ||
!selectedBaseSerie.breakdownSeries[selectedBreakdownIndex]
) {
return null;
}
return selectedBaseSerie.breakdownSeries[selectedBreakdownIndex];
}, [selectedBaseSerie, selectedBreakdownIndex]);
// Reset breakdown selection when base serie changes
const handleBaseSerieChange = (value: string) => {
setSelectedBaseSerieId(value);
setSelectedBreakdownIndex(null);
};
const selectedSerie = selectedBreakdown || selectedBaseSerie;
const profilesQuery = useQuery(
trpc.chart.getProfiles.queryOptions(
{
projectId: report.projectId,
date: date,
series:
selectedSerie &&
selectedBaseSerie?.reportSerie &&
selectedBaseSerie.reportSerie.type === 'event'
? [selectedBaseSerie.reportSerie]
: [],
breakdowns: selectedBreakdown?.breakdowns,
interval: report.interval,
},
{
enabled:
!!selectedSerie &&
!!selectedBaseSerie?.reportSerie &&
selectedBaseSerie.reportSerie.type === 'event',
},
),
);
const profiles = profilesQuery.data ?? [];
return (
<ModalContent>
<ModalHeader
title="View Users"
description={`Users who triggered this event on ${new Date(date).toLocaleDateString()}`}
/>
<ModalHeader title="View Users" />
<p className="text-sm text-muted-foreground mb-4">
Users who performed actions on {new Date(date).toLocaleDateString()}
</p>
<div className="flex flex-col gap-4">
{query.isLoading ? (
{baseSeries.length > 0 && (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<label className="text-sm font-medium">Serie:</label>
<Select
value={selectedBaseSerieId || ''}
onValueChange={handleBaseSerieChange}
>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="Select Serie" />
</SelectTrigger>
<SelectContent>
{baseSeries.map((baseSerie) => (
<SelectItem
key={baseSerie.baseEventId}
value={baseSerie.baseEventId}
>
{baseSerie.baseName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{selectedBaseSerie &&
selectedBaseSerie.breakdownSeries.length > 1 && (
<div className="flex items-center gap-2">
<label className="text-sm font-medium">Breakdown:</label>
<Select
value={selectedBreakdownIndex?.toString() || ''}
onValueChange={(value) =>
setSelectedBreakdownIndex(
value ? Number.parseInt(value, 10) : null,
)
}
>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="All Breakdowns" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">All Breakdowns</SelectItem>
{selectedBaseSerie.breakdownSeries.map((bdSerie, idx) => (
<SelectItem
key={bdSerie.serie.id}
value={idx.toString()}
>
{bdSerie.serie.names.slice(1).join(' > ') ||
'No Breakdown'}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
)}
{profilesQuery.isLoading ? (
<div className="flex items-center justify-center py-8">
<div className="text-muted-foreground">Loading users...</div>
</div>
@@ -109,4 +244,3 @@ export default function ViewChartUsers({
</ModalContent>
);
}