feat: report editor
commit bfcf271a64c33a60f61f511cec2198d9c8a9c51a Author: Carl-Gerhard Lindesvärd <lindesvard@gmail.com> Date: Wed Nov 26 12:32:40 2025 +0100 wip commit8cd3b89fa3Author: Carl-Gerhard Lindesvärd <lindesvard@gmail.com> Date: Tue Nov 25 22:33:58 2025 +0100 funnel commit95af86dc44Author: Carl-Gerhard Lindesvärd <lindesvard@gmail.com> Date: Tue Nov 25 22:23:25 2025 +0100 wip commit727a218e6bAuthor: Carl-Gerhard Lindesvärd <lindesvard@gmail.com> Date: Tue Nov 25 10:18:26 2025 +0100 conversion wip commit958ba535d6Author: Carl-Gerhard Lindesvärd <lindesvard@gmail.com> Date: Tue Nov 25 10:18:20 2025 +0100 wip commit3bbeb927ccAuthor: Carl-Gerhard Lindesvärd <lindesvard@gmail.com> Date: Tue Nov 25 09:18:48 2025 +0100 wip commitd99335e2f4Author: Carl-Gerhard Lindesvärd <lindesvard@gmail.com> Date: Mon Nov 24 18:08:10 2025 +0100 wip commit1fa61b1ae9Author: Carl-Gerhard Lindesvärd <lindesvard@gmail.com> Date: Mon Nov 24 15:50:28 2025 +0100 ts commit548747d826Author: Carl-Gerhard Lindesvärd <lindesvard@gmail.com> Date: Mon Nov 24 13:17:01 2025 +0100 fix typecheck events -> series commit7b18544085Author: Carl-Gerhard Lindesvärd <lindesvard@gmail.com> Date: Mon Nov 24 13:06:46 2025 +0100 fix report table commit57697a5a39Author: Carl-Gerhard Lindesvärd <lindesvard@gmail.com> Date: Sat Nov 22 00:05:13 2025 +0100 wip commit06fb6c4f3cAuthor: Carl-Gerhard Lindesvärd <lindesvard@gmail.com> Date: Fri Nov 21 11:21:17 2025 +0100 wip commitdd71fd4e11Author: Carl-Gerhard Lindesvärd <lindesvard@gmail.com> Date: Thu Nov 20 13:56:58 2025 +0100 formulas
This commit is contained in:
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@@ -17,7 +17,7 @@
|
|||||||
"editor.defaultFormatter": "biomejs.biome"
|
"editor.defaultFormatter": "biomejs.biome"
|
||||||
},
|
},
|
||||||
"[json]": {
|
"[json]": {
|
||||||
"editor.defaultFormatter": "biomejs.biome"
|
"editor.defaultFormatter": "vscode.json-language-features"
|
||||||
},
|
},
|
||||||
"editor.formatOnSave": true,
|
"editor.formatOnSave": true,
|
||||||
"tailwindCSS.experimental.classRegex": [
|
"tailwindCSS.experimental.classRegex": [
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ import {
|
|||||||
getEventsCountCached,
|
getEventsCountCached,
|
||||||
getSettingsForProject,
|
getSettingsForProject,
|
||||||
} from '@openpanel/db';
|
} from '@openpanel/db';
|
||||||
import { getChart } from '@openpanel/trpc/src/routers/chart.helpers';
|
import { ChartEngine } from '@openpanel/db';
|
||||||
import { zChartEvent, zChartInput } from '@openpanel/validation';
|
import { zChartEvent, zChartInputBase } from '@openpanel/validation';
|
||||||
import { omit } from 'ramda';
|
import { omit } from 'ramda';
|
||||||
|
|
||||||
async function getProjectId(
|
async function getProjectId(
|
||||||
@@ -139,7 +139,7 @@ export async function events(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const chartSchemeFull = zChartInput
|
const chartSchemeFull = zChartInputBase
|
||||||
.pick({
|
.pick({
|
||||||
breakdowns: true,
|
breakdowns: true,
|
||||||
interval: true,
|
interval: true,
|
||||||
@@ -151,14 +151,27 @@ const chartSchemeFull = zChartInput
|
|||||||
.extend({
|
.extend({
|
||||||
project_id: z.string().optional(),
|
project_id: z.string().optional(),
|
||||||
projectId: z.string().optional(),
|
projectId: z.string().optional(),
|
||||||
events: z.array(
|
series: z
|
||||||
z.object({
|
.array(
|
||||||
name: z.string(),
|
z.object({
|
||||||
filters: zChartEvent.shape.filters.optional(),
|
name: z.string(),
|
||||||
segment: zChartEvent.shape.segment.optional(),
|
filters: zChartEvent.shape.filters.optional(),
|
||||||
property: zChartEvent.shape.property.optional(),
|
segment: zChartEvent.shape.segment.optional(),
|
||||||
}),
|
property: zChartEvent.shape.property.optional(),
|
||||||
),
|
}),
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
// Backward compatibility - events will be migrated to series via preprocessing
|
||||||
|
events: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
name: z.string(),
|
||||||
|
filters: zChartEvent.shape.filters.optional(),
|
||||||
|
segment: zChartEvent.shape.segment.optional(),
|
||||||
|
property: zChartEvent.shape.property.optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function charts(
|
export async function charts(
|
||||||
@@ -179,9 +192,17 @@ export async function charts(
|
|||||||
|
|
||||||
const projectId = await getProjectId(request, reply);
|
const projectId = await getProjectId(request, reply);
|
||||||
const { timezone } = await getSettingsForProject(projectId);
|
const { timezone } = await getSettingsForProject(projectId);
|
||||||
const { events, ...rest } = query.data;
|
const { events, series, ...rest } = query.data;
|
||||||
|
|
||||||
return getChart({
|
// Use series if available, otherwise fall back to events (backward compat)
|
||||||
|
const eventSeries = (series ?? events ?? []).map((event: any) => ({
|
||||||
|
...event,
|
||||||
|
type: event.type ?? 'event',
|
||||||
|
segment: event.segment ?? 'event',
|
||||||
|
filters: event.filters ?? [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
return ChartEngine.execute({
|
||||||
...rest,
|
...rest,
|
||||||
startDate: rest.startDate
|
startDate: rest.startDate
|
||||||
? DateTime.fromISO(rest.startDate)
|
? DateTime.fromISO(rest.startDate)
|
||||||
@@ -194,11 +215,7 @@ export async function charts(
|
|||||||
.toFormat('yyyy-MM-dd HH:mm:ss')
|
.toFormat('yyyy-MM-dd HH:mm:ss')
|
||||||
: undefined,
|
: undefined,
|
||||||
projectId,
|
projectId,
|
||||||
events: events.map((event) => ({
|
series: eventSeries,
|
||||||
...event,
|
|
||||||
segment: event.segment ?? 'event',
|
|
||||||
filters: event.filters ?? [],
|
|
||||||
})),
|
|
||||||
chartType: 'linear',
|
chartType: 'linear',
|
||||||
metric: 'sum',
|
metric: 'sum',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import {
|
|||||||
ch,
|
ch,
|
||||||
clix,
|
clix,
|
||||||
} from '@openpanel/db';
|
} from '@openpanel/db';
|
||||||
|
import { ChartEngine } from '@openpanel/db';
|
||||||
import { getCache } from '@openpanel/redis';
|
import { getCache } from '@openpanel/redis';
|
||||||
import { getChart } from '@openpanel/trpc/src/routers/chart.helpers';
|
|
||||||
import { zChartInputAI } from '@openpanel/validation';
|
import { zChartInputAI } from '@openpanel/validation';
|
||||||
import { tool } from 'ai';
|
import { tool } from 'ai';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|||||||
@@ -103,7 +103,6 @@
|
|||||||
"lodash.throttle": "^4.1.1",
|
"lodash.throttle": "^4.1.1",
|
||||||
"lottie-react": "^2.4.0",
|
"lottie-react": "^2.4.0",
|
||||||
"lucide-react": "^0.476.0",
|
"lucide-react": "^0.476.0",
|
||||||
"mathjs": "^12.3.2",
|
|
||||||
"mitt": "^3.0.1",
|
"mitt": "^3.0.1",
|
||||||
"nuqs": "^2.5.2",
|
"nuqs": "^2.5.2",
|
||||||
"prisma-error-enum": "^0.1.3",
|
"prisma-error-enum": "^0.1.3",
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ export function OverviewMetricCard({
|
|||||||
width={width}
|
width={width}
|
||||||
height={height / 4}
|
height={height / 4}
|
||||||
data={data}
|
data={data}
|
||||||
style={{ marginTop: (height / 4) * 3 }}
|
style={{ marginTop: (height / 4) * 3, background: 'transparent' }}
|
||||||
onMouseMove={(event) => {
|
onMouseMove={(event) => {
|
||||||
setCurrentIndex(event.activeTooltipIndex ?? null);
|
setCurrentIndex(event.activeTooltipIndex ?? null);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -45,8 +45,9 @@ export default function OverviewTopDevices({
|
|||||||
projectId,
|
projectId,
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
events: [
|
series: [
|
||||||
{
|
{
|
||||||
|
type: 'event',
|
||||||
segment: 'user',
|
segment: 'user',
|
||||||
filters,
|
filters,
|
||||||
id: 'A',
|
id: 'A',
|
||||||
@@ -81,8 +82,9 @@ export default function OverviewTopDevices({
|
|||||||
projectId,
|
projectId,
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
events: [
|
series: [
|
||||||
{
|
{
|
||||||
|
type: 'event',
|
||||||
segment: 'user',
|
segment: 'user',
|
||||||
filters,
|
filters,
|
||||||
id: 'A',
|
id: 'A',
|
||||||
@@ -120,8 +122,9 @@ export default function OverviewTopDevices({
|
|||||||
projectId,
|
projectId,
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
events: [
|
series: [
|
||||||
{
|
{
|
||||||
|
type: 'event',
|
||||||
segment: 'user',
|
segment: 'user',
|
||||||
filters,
|
filters,
|
||||||
id: 'A',
|
id: 'A',
|
||||||
@@ -160,8 +163,9 @@ export default function OverviewTopDevices({
|
|||||||
projectId,
|
projectId,
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
events: [
|
series: [
|
||||||
{
|
{
|
||||||
|
type: 'event',
|
||||||
segment: 'user',
|
segment: 'user',
|
||||||
filters,
|
filters,
|
||||||
id: 'A',
|
id: 'A',
|
||||||
@@ -199,8 +203,9 @@ export default function OverviewTopDevices({
|
|||||||
projectId,
|
projectId,
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
events: [
|
series: [
|
||||||
{
|
{
|
||||||
|
type: 'event',
|
||||||
segment: 'user',
|
segment: 'user',
|
||||||
filters,
|
filters,
|
||||||
id: 'A',
|
id: 'A',
|
||||||
@@ -239,8 +244,9 @@ export default function OverviewTopDevices({
|
|||||||
projectId,
|
projectId,
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
events: [
|
series: [
|
||||||
{
|
{
|
||||||
|
type: 'event',
|
||||||
segment: 'user',
|
segment: 'user',
|
||||||
filters,
|
filters,
|
||||||
id: 'A',
|
id: 'A',
|
||||||
@@ -278,8 +284,9 @@ export default function OverviewTopDevices({
|
|||||||
projectId,
|
projectId,
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
events: [
|
series: [
|
||||||
{
|
{
|
||||||
|
type: 'event',
|
||||||
segment: 'user',
|
segment: 'user',
|
||||||
filters,
|
filters,
|
||||||
id: 'A',
|
id: 'A',
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type { IChartType } from '@openpanel/validation';
|
|||||||
|
|
||||||
import { useTRPC } from '@/integrations/trpc/react';
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { SerieIcon } from '../report-chart/common/serie-icon';
|
||||||
import { Widget, WidgetBody } from '../widget';
|
import { Widget, WidgetBody } from '../widget';
|
||||||
import { OverviewChartToggle } from './overview-chart-toggle';
|
import { OverviewChartToggle } from './overview-chart-toggle';
|
||||||
import { WidgetButtons, WidgetFooter, WidgetHead } from './overview-widget';
|
import { WidgetButtons, WidgetFooter, WidgetHead } from './overview-widget';
|
||||||
@@ -37,8 +38,9 @@ export default function OverviewTopEvents({
|
|||||||
projectId,
|
projectId,
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
events: [
|
series: [
|
||||||
{
|
{
|
||||||
|
type: 'event',
|
||||||
segment: 'event',
|
segment: 'event',
|
||||||
filters: [
|
filters: [
|
||||||
...filters,
|
...filters,
|
||||||
@@ -78,8 +80,9 @@ export default function OverviewTopEvents({
|
|||||||
projectId,
|
projectId,
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
events: [
|
series: [
|
||||||
{
|
{
|
||||||
|
type: 'event',
|
||||||
segment: 'event',
|
segment: 'event',
|
||||||
filters: [...filters],
|
filters: [...filters],
|
||||||
id: 'A',
|
id: 'A',
|
||||||
@@ -112,8 +115,9 @@ export default function OverviewTopEvents({
|
|||||||
projectId,
|
projectId,
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
events: [
|
series: [
|
||||||
{
|
{
|
||||||
|
type: 'event',
|
||||||
segment: 'event',
|
segment: 'event',
|
||||||
filters: [
|
filters: [
|
||||||
...filters,
|
...filters,
|
||||||
@@ -168,7 +172,13 @@ export default function OverviewTopEvents({
|
|||||||
</WidgetHead>
|
</WidgetHead>
|
||||||
<WidgetBody className="p-3">
|
<WidgetBody className="p-3">
|
||||||
<ReportChart
|
<ReportChart
|
||||||
options={{ hideID: true, columns: ['Event', 'Count'] }}
|
options={{
|
||||||
|
hideID: true,
|
||||||
|
columns: ['Event'],
|
||||||
|
renderSerieName(names) {
|
||||||
|
return names[1];
|
||||||
|
},
|
||||||
|
}}
|
||||||
report={{
|
report={{
|
||||||
...widget.chart.report,
|
...widget.chart.report,
|
||||||
previous: false,
|
previous: false,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type { IChartType } from '@openpanel/validation';
|
|||||||
import { useNumber } from '@/hooks/use-numer-formatter';
|
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||||
import { useTRPC } from '@/integrations/trpc/react';
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
import { pushModal } from '@/modals';
|
import { pushModal } from '@/modals';
|
||||||
|
import { countries } from '@/translations/countries';
|
||||||
import { NOT_SET_VALUE } from '@openpanel/constants';
|
import { NOT_SET_VALUE } from '@openpanel/constants';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { ChevronRightIcon } from 'lucide-react';
|
import { ChevronRightIcon } from 'lucide-react';
|
||||||
@@ -108,13 +109,19 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
|
|||||||
>
|
>
|
||||||
{item.prefix && (
|
{item.prefix && (
|
||||||
<span className="mr-1 row inline-flex items-center gap-1">
|
<span className="mr-1 row inline-flex items-center gap-1">
|
||||||
<span>{item.prefix}</span>
|
<span>
|
||||||
|
{countries[
|
||||||
|
item.prefix as keyof typeof countries
|
||||||
|
] ?? item.prefix}
|
||||||
|
</span>
|
||||||
<span>
|
<span>
|
||||||
<ChevronRightIcon className="size-3" />
|
<ChevronRightIcon className="size-3" />
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{item.name || 'Not set'}
|
{(countries[item.name as keyof typeof countries] ??
|
||||||
|
item.name) ||
|
||||||
|
'Not set'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -146,8 +153,9 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
|
|||||||
projectId,
|
projectId,
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
events: [
|
series: [
|
||||||
{
|
{
|
||||||
|
type: 'event',
|
||||||
segment: 'event',
|
segment: 'event',
|
||||||
filters,
|
filters,
|
||||||
id: 'A',
|
id: 'A',
|
||||||
|
|||||||
@@ -15,8 +15,9 @@ export const ProfileCharts = memo(
|
|||||||
const pageViewsChart: IChartProps = {
|
const pageViewsChart: IChartProps = {
|
||||||
projectId,
|
projectId,
|
||||||
chartType: 'linear',
|
chartType: 'linear',
|
||||||
events: [
|
series: [
|
||||||
{
|
{
|
||||||
|
type: 'event',
|
||||||
segment: 'event',
|
segment: 'event',
|
||||||
filters: [
|
filters: [
|
||||||
{
|
{
|
||||||
@@ -48,8 +49,9 @@ export const ProfileCharts = memo(
|
|||||||
const eventsChart: IChartProps = {
|
const eventsChart: IChartProps = {
|
||||||
projectId,
|
projectId,
|
||||||
chartType: 'linear',
|
chartType: 'linear',
|
||||||
events: [
|
series: [
|
||||||
{
|
{
|
||||||
|
type: 'event',
|
||||||
segment: 'event',
|
segment: 'event',
|
||||||
filters: [
|
filters: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { cn } from '@/utils/cn';
|
|||||||
import { getChartColor } from '@/utils/theme';
|
import { getChartColor } from '@/utils/theme';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { isSameDay, isSameHour, isSameMonth, isSameWeek } from 'date-fns';
|
import { isSameDay, isSameHour, isSameMonth, isSameWeek } from 'date-fns';
|
||||||
|
import { BookmarkIcon, UsersIcon } from 'lucide-react';
|
||||||
import { last } from 'ramda';
|
import { last } from 'ramda';
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
@@ -25,6 +26,10 @@ import {
|
|||||||
|
|
||||||
import { useDashedStroke } from '@/hooks/use-dashed-stroke';
|
import { useDashedStroke } from '@/hooks/use-dashed-stroke';
|
||||||
import { useXAxisProps, useYAxisProps } from '../common/axis';
|
import { useXAxisProps, useYAxisProps } from '../common/axis';
|
||||||
|
import {
|
||||||
|
ChartClickMenu,
|
||||||
|
type ChartClickMenuItem,
|
||||||
|
} from '../common/chart-click-menu';
|
||||||
import { ReportChartTooltip } from '../common/report-chart-tooltip';
|
import { ReportChartTooltip } from '../common/report-chart-tooltip';
|
||||||
import { ReportTable } from '../common/report-table';
|
import { ReportTable } from '../common/report-table';
|
||||||
import { SerieIcon } from '../common/serie-icon';
|
import { SerieIcon } from '../common/serie-icon';
|
||||||
@@ -45,6 +50,8 @@ export function Chart({ data }: Props) {
|
|||||||
endDate,
|
endDate,
|
||||||
range,
|
range,
|
||||||
lineType,
|
lineType,
|
||||||
|
series: reportSeries,
|
||||||
|
breakdowns,
|
||||||
},
|
},
|
||||||
isEditMode,
|
isEditMode,
|
||||||
options: { hideXAxis, hideYAxis },
|
options: { hideXAxis, hideYAxis },
|
||||||
@@ -126,16 +133,66 @@ export function Chart({ data }: Props) {
|
|||||||
interval,
|
interval,
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleChartClick = useCallback((e: any) => {
|
const getMenuItems = useCallback(
|
||||||
if (e?.activePayload?.[0]) {
|
(e: any, clickedData: any): ChartClickMenuItem[] => {
|
||||||
const clickedData = e.activePayload[0].payload;
|
const items: ChartClickMenuItem[] = [];
|
||||||
if (clickedData.date) {
|
|
||||||
pushModal('AddReference', {
|
if (!clickedData?.date) {
|
||||||
datetime: new Date(clickedData.date).toISOString(),
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
// View Users - only show if we have projectId
|
||||||
|
if (projectId) {
|
||||||
|
items.push({
|
||||||
|
label: 'View Users',
|
||||||
|
icon: <UsersIcon size={16} />,
|
||||||
|
onClick: () => {
|
||||||
|
pushModal('ViewChartUsers', {
|
||||||
|
type: 'chart',
|
||||||
|
chartData: data,
|
||||||
|
report: {
|
||||||
|
projectId,
|
||||||
|
series: reportSeries,
|
||||||
|
breakdowns: breakdowns || [],
|
||||||
|
interval,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
range,
|
||||||
|
previous,
|
||||||
|
chartType: 'area',
|
||||||
|
metric: 'sum',
|
||||||
|
},
|
||||||
|
date: clickedData.date,
|
||||||
|
});
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}, []);
|
// Add Reference - always show
|
||||||
|
items.push({
|
||||||
|
label: 'Add Reference',
|
||||||
|
icon: <BookmarkIcon size={16} />,
|
||||||
|
onClick: () => {
|
||||||
|
pushModal('AddReference', {
|
||||||
|
datetime: new Date(clickedData.date).toISOString(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return items;
|
||||||
|
},
|
||||||
|
[
|
||||||
|
projectId,
|
||||||
|
data,
|
||||||
|
reportSeries,
|
||||||
|
breakdowns,
|
||||||
|
interval,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
range,
|
||||||
|
previous,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
const { getStrokeDasharray, calcStrokeDasharray, handleAnimationEnd } =
|
const { getStrokeDasharray, calcStrokeDasharray, handleAnimationEnd } =
|
||||||
useDashedStroke({
|
useDashedStroke({
|
||||||
@@ -144,106 +201,114 @@ export function Chart({ data }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ReportChartTooltip.TooltipProvider references={references.data}>
|
<ReportChartTooltip.TooltipProvider references={references.data}>
|
||||||
<div className={cn('h-full w-full', isEditMode && 'card p-4')}>
|
<ChartClickMenu getMenuItems={getMenuItems}>
|
||||||
<ResponsiveContainer>
|
<div className={cn('h-full w-full', isEditMode && 'card p-4')}>
|
||||||
<ComposedChart data={rechartData} onClick={handleChartClick}>
|
<ResponsiveContainer>
|
||||||
<Customized component={calcStrokeDasharray} />
|
<ComposedChart data={rechartData}>
|
||||||
<Line
|
<Customized component={calcStrokeDasharray} />
|
||||||
dataKey="calcStrokeDasharray"
|
<Line
|
||||||
legendType="none"
|
dataKey="calcStrokeDasharray"
|
||||||
animationDuration={0}
|
legendType="none"
|
||||||
onAnimationEnd={handleAnimationEnd}
|
animationDuration={0}
|
||||||
/>
|
onAnimationEnd={handleAnimationEnd}
|
||||||
<CartesianGrid
|
|
||||||
strokeDasharray="3 3"
|
|
||||||
horizontal={true}
|
|
||||||
vertical={false}
|
|
||||||
className="stroke-border"
|
|
||||||
/>
|
|
||||||
{references.data?.map((ref) => (
|
|
||||||
<ReferenceLine
|
|
||||||
key={ref.id}
|
|
||||||
x={ref.date.getTime()}
|
|
||||||
stroke={'oklch(from var(--foreground) l c h / 0.1)'}
|
|
||||||
strokeDasharray={'3 3'}
|
|
||||||
label={{
|
|
||||||
value: ref.title,
|
|
||||||
position: 'centerTop',
|
|
||||||
fill: '#334155',
|
|
||||||
fontSize: 12,
|
|
||||||
}}
|
|
||||||
fontSize={10}
|
|
||||||
/>
|
/>
|
||||||
))}
|
<CartesianGrid
|
||||||
<YAxis {...yAxisProps} />
|
strokeDasharray="3 3"
|
||||||
<XAxis {...xAxisProps} />
|
horizontal={true}
|
||||||
<Legend content={<CustomLegend />} />
|
vertical={false}
|
||||||
<Tooltip content={<ReportChartTooltip.Tooltip />} />
|
className="stroke-border"
|
||||||
{series.map((serie) => {
|
/>
|
||||||
const color = getChartColor(serie.index);
|
{references.data?.map((ref) => (
|
||||||
return (
|
<ReferenceLine
|
||||||
<defs key={`defs-${serie.id}`}>
|
key={ref.id}
|
||||||
<linearGradient
|
x={ref.date.getTime()}
|
||||||
id={`color${color}`}
|
stroke={'oklch(from var(--foreground) l c h / 0.1)'}
|
||||||
x1="0"
|
strokeDasharray={'3 3'}
|
||||||
y1="0"
|
label={{
|
||||||
x2="0"
|
value: ref.title,
|
||||||
y2="1"
|
position: 'centerTop',
|
||||||
>
|
fill: '#334155',
|
||||||
<stop offset="0%" stopColor={color} stopOpacity={0.8} />
|
fontSize: 12,
|
||||||
<stop offset={'100%'} stopColor={color} stopOpacity={0.1} />
|
}}
|
||||||
</linearGradient>
|
fontSize={10}
|
||||||
</defs>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{series.map((serie) => {
|
|
||||||
const color = getChartColor(serie.index);
|
|
||||||
return (
|
|
||||||
<Area
|
|
||||||
key={serie.id}
|
|
||||||
stackId="1"
|
|
||||||
type={lineType}
|
|
||||||
name={serie.id}
|
|
||||||
dataKey={`${serie.id}:count`}
|
|
||||||
strokeDasharray={
|
|
||||||
useDashedLastLine
|
|
||||||
? getStrokeDasharray(`${serie.id}:count`)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
fill={`url(#color${color})`}
|
|
||||||
isAnimationActive={false}
|
|
||||||
fillOpacity={0.7}
|
|
||||||
/>
|
/>
|
||||||
);
|
))}
|
||||||
})}
|
<YAxis {...yAxisProps} />
|
||||||
{previous &&
|
<XAxis {...xAxisProps} />
|
||||||
series.map((serie) => {
|
<Legend content={<CustomLegend />} />
|
||||||
|
<Tooltip content={<ReportChartTooltip.Tooltip />} />
|
||||||
|
{series.map((serie) => {
|
||||||
|
const color = getChartColor(serie.index);
|
||||||
|
return (
|
||||||
|
<defs key={`defs-${serie.id}`}>
|
||||||
|
<linearGradient
|
||||||
|
id={`color${color}`}
|
||||||
|
x1="0"
|
||||||
|
y1="0"
|
||||||
|
x2="0"
|
||||||
|
y2="1"
|
||||||
|
>
|
||||||
|
<stop offset="0%" stopColor={color} stopOpacity={0.8} />
|
||||||
|
<stop
|
||||||
|
offset={'100%'}
|
||||||
|
stopColor={color}
|
||||||
|
stopOpacity={0.1}
|
||||||
|
/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{series.map((serie) => {
|
||||||
const color = getChartColor(serie.index);
|
const color = getChartColor(serie.index);
|
||||||
return (
|
return (
|
||||||
<Area
|
<Area
|
||||||
key={`${serie.id}:prev`}
|
key={serie.id}
|
||||||
stackId="2"
|
stackId="1"
|
||||||
type={lineType}
|
type={lineType}
|
||||||
name={`${serie.id}:prev`}
|
name={serie.id}
|
||||||
dataKey={`${serie.id}:prev:count`}
|
dataKey={`${serie.id}:count`}
|
||||||
|
strokeDasharray={
|
||||||
|
useDashedLastLine
|
||||||
|
? getStrokeDasharray(`${serie.id}:count`)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
fill={`url(#color${color})`}
|
||||||
stroke={color}
|
stroke={color}
|
||||||
fill={color}
|
strokeWidth={2}
|
||||||
fillOpacity={0.3}
|
|
||||||
strokeOpacity={0.3}
|
|
||||||
isAnimationActive={false}
|
isAnimationActive={false}
|
||||||
|
fillOpacity={0.7}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ComposedChart>
|
{previous &&
|
||||||
</ResponsiveContainer>
|
series.map((serie) => {
|
||||||
</div>
|
const color = getChartColor(serie.index);
|
||||||
{isEditMode && (
|
return (
|
||||||
<ReportTable
|
<Area
|
||||||
data={data}
|
key={`${serie.id}:prev`}
|
||||||
visibleSeries={series}
|
stackId="2"
|
||||||
setVisibleSeries={setVisibleSeries}
|
type={lineType}
|
||||||
/>
|
name={`${serie.id}:prev`}
|
||||||
)}
|
dataKey={`${serie.id}:prev:count`}
|
||||||
|
stroke={color}
|
||||||
|
fill={color}
|
||||||
|
fillOpacity={0.3}
|
||||||
|
strokeOpacity={0.3}
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ComposedChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
{isEditMode && (
|
||||||
|
<ReportTable
|
||||||
|
data={data}
|
||||||
|
visibleSeries={series}
|
||||||
|
setVisibleSeries={setVisibleSeries}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ChartClickMenu>
|
||||||
</ReportChartTooltip.TooltipProvider>
|
</ReportChartTooltip.TooltipProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,263 @@
|
|||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import React, {
|
||||||
|
forwardRef,
|
||||||
|
useCallback,
|
||||||
|
useImperativeHandle,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
|
export interface ChartClickMenuItem {
|
||||||
|
label: string;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
onClick: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChartClickMenuProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
/**
|
||||||
|
* Function that receives the click event and clicked data, returns menu items
|
||||||
|
* This allows conditional menu items based on what was clicked
|
||||||
|
*/
|
||||||
|
getMenuItems: (e: any, clickedData: any) => ChartClickMenuItem[];
|
||||||
|
/**
|
||||||
|
* Optional callback when menu closes
|
||||||
|
*/
|
||||||
|
onClose?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChartClickMenuHandle {
|
||||||
|
setPosition: (position: { x: number; y: number } | null) => void;
|
||||||
|
getContainerElement: () => HTMLDivElement | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reusable component for handling chart clicks and showing a dropdown menu
|
||||||
|
* Wraps the chart and handles click position tracking and dropdown positioning
|
||||||
|
*/
|
||||||
|
export const ChartClickMenu = forwardRef<
|
||||||
|
ChartClickMenuHandle,
|
||||||
|
ChartClickMenuProps
|
||||||
|
>(({ children, getMenuItems, onClose }, ref) => {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [clickPosition, setClickPosition] = useState<{
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
} | null>(null);
|
||||||
|
const [clickedData, setClickedData] = useState<any>(null);
|
||||||
|
|
||||||
|
const [clickEvent, setClickEvent] = useState<any>(null);
|
||||||
|
|
||||||
|
const handleChartClick = useCallback((e: any) => {
|
||||||
|
if (e?.activePayload?.[0] && containerRef.current) {
|
||||||
|
const payload = e.activePayload[0].payload;
|
||||||
|
|
||||||
|
// Calculate click position relative to chart container
|
||||||
|
const containerRect = containerRef.current.getBoundingClientRect();
|
||||||
|
|
||||||
|
// Try to get viewport coordinates from the event
|
||||||
|
// Recharts passes nativeEvent with clientX/clientY (viewport coordinates)
|
||||||
|
let clientX = 0;
|
||||||
|
let clientY = 0;
|
||||||
|
|
||||||
|
if (
|
||||||
|
e.nativeEvent?.clientX !== undefined &&
|
||||||
|
e.nativeEvent?.clientY !== undefined
|
||||||
|
) {
|
||||||
|
// Best case: use nativeEvent client coordinates (viewport coordinates)
|
||||||
|
clientX = e.nativeEvent.clientX;
|
||||||
|
clientY = e.nativeEvent.clientY;
|
||||||
|
} else if (e.clientX !== undefined && e.clientY !== undefined) {
|
||||||
|
// Fallback: use event's clientX/Y directly
|
||||||
|
clientX = e.clientX;
|
||||||
|
clientY = e.clientY;
|
||||||
|
} else if (e.activeCoordinate) {
|
||||||
|
// Last resort: activeCoordinate is SVG-relative, need to find SVG element
|
||||||
|
// and convert to viewport coordinates
|
||||||
|
const svgElement = containerRef.current.querySelector('svg');
|
||||||
|
if (svgElement) {
|
||||||
|
const svgRect = svgElement.getBoundingClientRect();
|
||||||
|
clientX = svgRect.left + (e.activeCoordinate.x ?? 0);
|
||||||
|
clientY = svgRect.top + (e.activeCoordinate.y ?? 0);
|
||||||
|
} else {
|
||||||
|
// If no SVG found, use container position + activeCoordinate
|
||||||
|
clientX = containerRect.left + (e.activeCoordinate.x ?? 0);
|
||||||
|
clientY = containerRect.top + (e.activeCoordinate.y ?? 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setClickedData(payload);
|
||||||
|
setClickEvent(e); // Store the full event
|
||||||
|
setClickPosition({
|
||||||
|
x: clientX - containerRect.left,
|
||||||
|
y: clientY - containerRect.top,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const menuItems =
|
||||||
|
clickedData && clickEvent ? getMenuItems(clickEvent, clickedData) : [];
|
||||||
|
|
||||||
|
const handleItemClick = useCallback(
|
||||||
|
(item: ChartClickMenuItem) => {
|
||||||
|
item.onClick();
|
||||||
|
setClickPosition(null);
|
||||||
|
setClickedData(null);
|
||||||
|
setClickEvent(null);
|
||||||
|
if (onClose) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onClose],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleOpenChange = useCallback(
|
||||||
|
(open: boolean) => {
|
||||||
|
if (!open) {
|
||||||
|
setClickPosition(null);
|
||||||
|
setClickedData(null);
|
||||||
|
setClickEvent(null);
|
||||||
|
if (onClose) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onClose],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Expose methods via ref (for advanced use cases)
|
||||||
|
useImperativeHandle(
|
||||||
|
ref,
|
||||||
|
() => ({
|
||||||
|
setPosition: (position: { x: number; y: number } | null) => {
|
||||||
|
setClickPosition(position);
|
||||||
|
},
|
||||||
|
getContainerElement: () => containerRef.current,
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Clone children and add onClick handler to chart components
|
||||||
|
const chartWithClickHandler = React.useMemo(() => {
|
||||||
|
const addClickHandler = (node: React.ReactNode): React.ReactNode => {
|
||||||
|
// Handle null, undefined, strings, numbers
|
||||||
|
if (!React.isValidElement(node)) {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is a chart component
|
||||||
|
const componentName =
|
||||||
|
(node.type as any)?.displayName || (node.type as any)?.name;
|
||||||
|
const isChartComponent =
|
||||||
|
componentName === 'ComposedChart' ||
|
||||||
|
componentName === 'LineChart' ||
|
||||||
|
componentName === 'BarChart' ||
|
||||||
|
componentName === 'AreaChart' ||
|
||||||
|
componentName === 'PieChart' ||
|
||||||
|
componentName === 'ResponsiveContainer';
|
||||||
|
|
||||||
|
// Process children recursively - handle arrays, fragments, and single elements
|
||||||
|
const processChildren = (children: React.ReactNode): React.ReactNode => {
|
||||||
|
if (children == null) {
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle arrays
|
||||||
|
if (Array.isArray(children)) {
|
||||||
|
return children.map(addClickHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle React fragments
|
||||||
|
if (
|
||||||
|
React.isValidElement(children) &&
|
||||||
|
children.type === React.Fragment
|
||||||
|
) {
|
||||||
|
const fragmentElement = children as React.ReactElement<{
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}>;
|
||||||
|
return React.cloneElement(fragmentElement, {
|
||||||
|
children: processChildren(fragmentElement.props.children),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recursively process single child
|
||||||
|
return addClickHandler(children);
|
||||||
|
};
|
||||||
|
|
||||||
|
const element = node as React.ReactElement<{
|
||||||
|
children?: React.ReactNode;
|
||||||
|
onClick?: (e: any) => void;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
if (isChartComponent) {
|
||||||
|
// For ResponsiveContainer, we need to add onClick to its child (ComposedChart, etc.)
|
||||||
|
if (componentName === 'ResponsiveContainer') {
|
||||||
|
return React.cloneElement(element, {
|
||||||
|
children: processChildren(element.props.children),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// For chart components, add onClick directly
|
||||||
|
return React.cloneElement(element, {
|
||||||
|
onClick: handleChartClick,
|
||||||
|
children: processChildren(element.props.children),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recursively process children for non-chart components
|
||||||
|
if (element.props.children != null) {
|
||||||
|
return React.cloneElement(element, {
|
||||||
|
children: processChildren(element.props.children),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return node;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle multiple children (array) or single child
|
||||||
|
if (Array.isArray(children)) {
|
||||||
|
return children.map(addClickHandler);
|
||||||
|
}
|
||||||
|
return addClickHandler(children);
|
||||||
|
}, [children, handleChartClick]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className="relative h-full w-full">
|
||||||
|
<DropdownMenu
|
||||||
|
open={clickPosition !== null}
|
||||||
|
onOpenChange={handleOpenChange}
|
||||||
|
>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
left: clickPosition?.x ?? -9999,
|
||||||
|
top: clickPosition?.y ?? -9999,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start" side="bottom" sideOffset={5}>
|
||||||
|
{menuItems.map((item) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={item.label}
|
||||||
|
onClick={() => handleItemClick(item)}
|
||||||
|
disabled={item.disabled}
|
||||||
|
>
|
||||||
|
{item.icon && <span className="mr-2">{item.icon}</span>}
|
||||||
|
{item.label}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
{chartWithClickHandler}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
ChartClickMenu.displayName = 'ChartClickMenu';
|
||||||
@@ -17,10 +17,10 @@ export function ReportChartEmpty({
|
|||||||
}) {
|
}) {
|
||||||
const {
|
const {
|
||||||
isEditMode,
|
isEditMode,
|
||||||
report: { events },
|
report: { series },
|
||||||
} = useReportChartContext();
|
} = useReportChartContext();
|
||||||
|
|
||||||
if (events.length === 0) {
|
if (!series || series.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="card p-4 center-center h-full w-full flex-col relative">
|
<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">
|
<div className="row gap-2 items-end absolute top-4 left-4">
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
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">
|
||||||
|
{onSearchChange && (
|
||||||
|
<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">
|
||||||
|
{onToggleGrouped && (
|
||||||
|
<Button
|
||||||
|
variant={'outline'}
|
||||||
|
size="sm"
|
||||||
|
onClick={onToggleGrouped}
|
||||||
|
icon={grouped ? Rows3 : List}
|
||||||
|
>
|
||||||
|
{grouped ? 'Grouped' : 'Flat'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{onUnselectAll && (
|
||||||
|
<Button variant="outline" size="sm" onClick={onUnselectAll} icon={X}>
|
||||||
|
Unselect All
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,742 @@
|
|||||||
|
import { getPropertyLabel } from '@/translations/properties';
|
||||||
|
import type { IChartData } from '@/trpc/client';
|
||||||
|
|
||||||
|
export type TableRow = {
|
||||||
|
id: string;
|
||||||
|
serieId: string; // Serie ID for visibility/color lookup
|
||||||
|
serieName: string;
|
||||||
|
breakdownValues: string[];
|
||||||
|
count: number;
|
||||||
|
sum: number;
|
||||||
|
average: number;
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
dateValues: Record<string, number>; // date -> count
|
||||||
|
// Group metadata
|
||||||
|
groupKey?: string;
|
||||||
|
parentGroupKey?: string;
|
||||||
|
isSummaryRow?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GroupedTableRow = TableRow & {
|
||||||
|
// For grouped mode, indicates which breakdown levels should show empty cells
|
||||||
|
breakdownDisplay: (string | null)[]; // null means show empty cell
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Row type that supports TanStack Table's expanding feature
|
||||||
|
* Can represent both group header rows and data rows
|
||||||
|
*/
|
||||||
|
export type ExpandableTableRow = TableRow & {
|
||||||
|
subRows?: ExpandableTableRow[];
|
||||||
|
isGroupHeader?: boolean; // True if this is a group header row
|
||||||
|
groupValue?: string; // The value this group represents
|
||||||
|
groupLevel?: number; // The level in the hierarchy (0-based)
|
||||||
|
breakdownDisplay?: (string | null)[]; // For display purposes
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hierarchical group structure for better collapse/expand functionality
|
||||||
|
*/
|
||||||
|
export type GroupedItem<T> = {
|
||||||
|
group: string;
|
||||||
|
items: Array<GroupedItem<T> | T>;
|
||||||
|
level: number;
|
||||||
|
groupKey: string; // Unique key for this group (path-based)
|
||||||
|
parentGroupKey?: string; // Key of parent group
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transform flat array of items with hierarchical names into nested group structure
|
||||||
|
* This creates a tree structure that makes it easier to toggle specific groups
|
||||||
|
*/
|
||||||
|
export function groupByNames<T extends { names: string[] }>(
|
||||||
|
items: T[],
|
||||||
|
): Array<GroupedItem<T>> {
|
||||||
|
const rootGroups = new Map<string, GroupedItem<T>>();
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const names = item.names;
|
||||||
|
if (names.length === 0) continue;
|
||||||
|
|
||||||
|
// Start with the first level (serie name, level -1)
|
||||||
|
const firstLevel = names[0]!;
|
||||||
|
const rootGroupKey = firstLevel;
|
||||||
|
|
||||||
|
if (!rootGroups.has(firstLevel)) {
|
||||||
|
rootGroups.set(firstLevel, {
|
||||||
|
group: firstLevel,
|
||||||
|
items: [],
|
||||||
|
level: -1, // Serie level
|
||||||
|
groupKey: rootGroupKey,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootGroup = rootGroups.get(firstLevel)!;
|
||||||
|
|
||||||
|
// Navigate/create nested groups for remaining levels (breakdowns, level 0+)
|
||||||
|
let currentGroup = rootGroup;
|
||||||
|
let parentGroupKey = rootGroupKey;
|
||||||
|
|
||||||
|
for (let i = 1; i < names.length; i++) {
|
||||||
|
const levelName = names[i]!;
|
||||||
|
const groupKey = `${parentGroupKey}:${levelName}`;
|
||||||
|
const level = i - 1; // Breakdown levels start at 0
|
||||||
|
|
||||||
|
// Find existing group at this level
|
||||||
|
const existingGroup = currentGroup.items.find(
|
||||||
|
(child): child is GroupedItem<T> =>
|
||||||
|
typeof child === 'object' &&
|
||||||
|
'group' in child &&
|
||||||
|
child.group === levelName &&
|
||||||
|
'level' in child &&
|
||||||
|
child.level === level,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingGroup) {
|
||||||
|
currentGroup = existingGroup;
|
||||||
|
parentGroupKey = groupKey;
|
||||||
|
} else {
|
||||||
|
// Create new group at this level
|
||||||
|
const newGroup: GroupedItem<T> = {
|
||||||
|
group: levelName,
|
||||||
|
items: [],
|
||||||
|
level,
|
||||||
|
groupKey,
|
||||||
|
parentGroupKey,
|
||||||
|
};
|
||||||
|
currentGroup.items.push(newGroup);
|
||||||
|
currentGroup = newGroup;
|
||||||
|
parentGroupKey = groupKey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the actual item to the deepest group
|
||||||
|
currentGroup.items.push(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(rootGroups.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flatten a grouped structure back into a flat array of items
|
||||||
|
* Useful for getting all items in a group or its children
|
||||||
|
*/
|
||||||
|
export function flattenGroupedItems<T>(
|
||||||
|
groupedItems: Array<GroupedItem<T> | T>,
|
||||||
|
): T[] {
|
||||||
|
const result: T[] = [];
|
||||||
|
|
||||||
|
for (const item of groupedItems) {
|
||||||
|
if (item && typeof item === 'object' && 'items' in item) {
|
||||||
|
// It's a group, recursively flatten its items
|
||||||
|
result.push(...flattenGroupedItems(item.items));
|
||||||
|
} else if (item) {
|
||||||
|
// It's an actual item
|
||||||
|
result.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a group by its groupKey in a nested structure
|
||||||
|
*/
|
||||||
|
export function findGroup<T>(
|
||||||
|
groups: Array<GroupedItem<T>>,
|
||||||
|
groupKey: string,
|
||||||
|
): GroupedItem<T> | null {
|
||||||
|
for (const group of groups) {
|
||||||
|
if (group.groupKey === groupKey) {
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search in nested groups
|
||||||
|
for (const item of group.items) {
|
||||||
|
if (item && typeof item === 'object' && 'items' in item) {
|
||||||
|
const found = findGroup([item], groupKey);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert hierarchical groups to TanStack Table's expandable row format
|
||||||
|
*
|
||||||
|
* Transforms nested GroupedItem structure into flat ExpandableTableRow array
|
||||||
|
* that TanStack Table can use with its native expanding feature.
|
||||||
|
*
|
||||||
|
* Key behaviors:
|
||||||
|
* - Serie level (level -1) and breakdown levels 0 to breakdownCount-2 create group headers
|
||||||
|
* - Last breakdown level (breakdownCount-1) does NOT create group headers (always individual rows)
|
||||||
|
* - Individual rows are explicitly marked as NOT group headers or summary rows
|
||||||
|
*/
|
||||||
|
export function groupsToExpandableRows(
|
||||||
|
groups: Array<GroupedItem<TableRow>>,
|
||||||
|
breakdownCount: number,
|
||||||
|
): ExpandableTableRow[] {
|
||||||
|
const result: ExpandableTableRow[] = [];
|
||||||
|
|
||||||
|
function processGroup(
|
||||||
|
group: GroupedItem<TableRow>,
|
||||||
|
parentPath: string[] = [],
|
||||||
|
): ExpandableTableRow[] {
|
||||||
|
const currentPath = [...parentPath, group.group];
|
||||||
|
const subRows: ExpandableTableRow[] = [];
|
||||||
|
|
||||||
|
// Separate nested groups from individual data items
|
||||||
|
const nestedGroups: GroupedItem<TableRow>[] = [];
|
||||||
|
const individualItems: TableRow[] = [];
|
||||||
|
|
||||||
|
for (const item of group.items) {
|
||||||
|
if (item && typeof item === 'object' && 'items' in item) {
|
||||||
|
nestedGroups.push(item);
|
||||||
|
} else if (item) {
|
||||||
|
individualItems.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process nested groups recursively (they become expandable group headers)
|
||||||
|
for (const nestedGroup of nestedGroups) {
|
||||||
|
subRows.push(...processGroup(nestedGroup, currentPath));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process individual data items (leaf nodes)
|
||||||
|
individualItems.forEach((item, index) => {
|
||||||
|
// Build breakdownDisplay: first row shows all values, subsequent rows show parent path + item values
|
||||||
|
const breakdownDisplay: (string | null)[] = [];
|
||||||
|
const breakdownValues = item.breakdownValues;
|
||||||
|
|
||||||
|
for (let i = 0; i < breakdownCount; i++) {
|
||||||
|
if (index === 0) {
|
||||||
|
// First row: show all breakdown values
|
||||||
|
breakdownDisplay.push(breakdownValues[i] ?? null);
|
||||||
|
} else {
|
||||||
|
// Subsequent rows: show parent path values, then item values
|
||||||
|
if (i < currentPath.length) {
|
||||||
|
breakdownDisplay.push(currentPath[i] ?? null);
|
||||||
|
} else if (i < breakdownValues.length) {
|
||||||
|
breakdownDisplay.push(breakdownValues[i] ?? null);
|
||||||
|
} else {
|
||||||
|
breakdownDisplay.push(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
subRows.push({
|
||||||
|
...item,
|
||||||
|
breakdownDisplay,
|
||||||
|
groupKey: group.groupKey,
|
||||||
|
parentGroupKey: group.parentGroupKey,
|
||||||
|
isGroupHeader: false,
|
||||||
|
isSummaryRow: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// If this group has subRows and is not the last breakdown level, create a group header row
|
||||||
|
// Don't create group headers for the last breakdown level (level === breakdownCount - 1)
|
||||||
|
// because the last breakdown should always be individual rows
|
||||||
|
// -1 is serie level (should be grouped)
|
||||||
|
// 0 to breakdownCount-2 are breakdown levels (should be grouped)
|
||||||
|
// breakdownCount-1 is the last breakdown level (should NOT be grouped, always individual)
|
||||||
|
const shouldCreateGroupHeader =
|
||||||
|
subRows.length > 0 &&
|
||||||
|
(group.level === -1 || group.level < breakdownCount - 1);
|
||||||
|
|
||||||
|
if (shouldCreateGroupHeader) {
|
||||||
|
// Create a summary row for the group
|
||||||
|
const groupItems = flattenGroupedItems(group.items);
|
||||||
|
const summaryRow = createSummaryRow(
|
||||||
|
groupItems,
|
||||||
|
group.groupKey,
|
||||||
|
breakdownCount,
|
||||||
|
);
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
...summaryRow,
|
||||||
|
isGroupHeader: true,
|
||||||
|
groupValue: group.group,
|
||||||
|
groupLevel: group.level,
|
||||||
|
subRows,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return subRows;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const group of groups) {
|
||||||
|
result.push(...processGroup(group));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert hierarchical groups to flat table rows, respecting collapsed groups
|
||||||
|
* This creates GroupedTableRow entries with proper breakdownDisplay values
|
||||||
|
* @deprecated Use groupsToExpandableRows with TanStack Table's expanding feature instead
|
||||||
|
*/
|
||||||
|
export function groupsToTableRows<T extends TableRow>(
|
||||||
|
groups: Array<GroupedItem<T>>,
|
||||||
|
collapsedGroups: Set<string>,
|
||||||
|
breakdownCount: number,
|
||||||
|
): GroupedTableRow[] {
|
||||||
|
const rows: GroupedTableRow[] = [];
|
||||||
|
|
||||||
|
function processGroup(
|
||||||
|
group: GroupedItem<T>,
|
||||||
|
parentPath: string[] = [],
|
||||||
|
parentGroupKey?: string,
|
||||||
|
): void {
|
||||||
|
const isGroupCollapsed = collapsedGroups.has(group.groupKey);
|
||||||
|
const currentPath = [...parentPath, group.group];
|
||||||
|
|
||||||
|
if (isGroupCollapsed) {
|
||||||
|
// Group is collapsed - add summary row
|
||||||
|
const groupItems = flattenGroupedItems(group.items);
|
||||||
|
if (groupItems.length > 0) {
|
||||||
|
const summaryRow = createSummaryRow(
|
||||||
|
groupItems,
|
||||||
|
group.groupKey,
|
||||||
|
breakdownCount,
|
||||||
|
);
|
||||||
|
rows.push(summaryRow);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group is expanded - process items
|
||||||
|
// Separate nested groups from actual items
|
||||||
|
const nestedGroups: GroupedItem<T>[] = [];
|
||||||
|
const actualItems: T[] = [];
|
||||||
|
|
||||||
|
for (const item of group.items) {
|
||||||
|
if (item && typeof item === 'object' && 'items' in item) {
|
||||||
|
nestedGroups.push(item);
|
||||||
|
} else if (item) {
|
||||||
|
actualItems.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process actual items first
|
||||||
|
actualItems.forEach((item, index) => {
|
||||||
|
const breakdownDisplay: (string | null)[] = [];
|
||||||
|
const breakdownValues = item.breakdownValues;
|
||||||
|
|
||||||
|
// For the first item in the group, show all breakdown values
|
||||||
|
// For subsequent items, show values based on hierarchy
|
||||||
|
if (index === 0) {
|
||||||
|
// First row shows all breakdown values
|
||||||
|
for (let i = 0; i < breakdownCount; i++) {
|
||||||
|
breakdownDisplay.push(breakdownValues[i] ?? null);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Subsequent rows: show values from parent path, then item values
|
||||||
|
for (let i = 0; i < breakdownCount; i++) {
|
||||||
|
if (i < currentPath.length) {
|
||||||
|
// Show value from parent group path
|
||||||
|
breakdownDisplay.push(currentPath[i] ?? null);
|
||||||
|
} else if (i < breakdownValues.length) {
|
||||||
|
// Show current breakdown value from the item
|
||||||
|
breakdownDisplay.push(breakdownValues[i] ?? null);
|
||||||
|
} else {
|
||||||
|
breakdownDisplay.push(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.push({
|
||||||
|
...item,
|
||||||
|
breakdownDisplay,
|
||||||
|
groupKey: group.groupKey,
|
||||||
|
parentGroupKey: group.parentGroupKey,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Process nested groups
|
||||||
|
for (const nestedGroup of nestedGroups) {
|
||||||
|
processGroup(nestedGroup, currentPath, group.groupKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const group of groups) {
|
||||||
|
processGroup(group);
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
|
serieId: serie.id,
|
||||||
|
serieName: serie.names[0] ?? '',
|
||||||
|
breakdownValues: serie.names.slice(1),
|
||||||
|
count: serie.metrics.count ?? 0,
|
||||||
|
sum: serie.metrics.sum,
|
||||||
|
average: serie.metrics.average,
|
||||||
|
min: serie.metrics.min,
|
||||||
|
max: serie.metrics.max,
|
||||||
|
dateValues,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transform series into hierarchical groups
|
||||||
|
* Uses the new groupByNames function for better structure
|
||||||
|
* Groups by serie name first, then by breakdown values
|
||||||
|
*/
|
||||||
|
export function createGroupedRowsHierarchical(
|
||||||
|
series: IChartData['series'],
|
||||||
|
dates: string[],
|
||||||
|
): Array<GroupedItem<TableRow>> {
|
||||||
|
const flatRows = createFlatRows(series, dates);
|
||||||
|
|
||||||
|
// Sort by sum descending before grouping
|
||||||
|
flatRows.sort((a, b) => b.sum - a.sum);
|
||||||
|
|
||||||
|
const breakdownCount = flatRows[0]?.breakdownValues.length ?? 0;
|
||||||
|
|
||||||
|
if (breakdownCount === 0) {
|
||||||
|
// No breakdowns - return empty array (will be handled as flat rows)
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create hierarchical groups using groupByNames
|
||||||
|
// Note: groupByNames expects items with a `names` array, so we create a temporary array
|
||||||
|
// This is a minor inefficiency but keeps groupByNames generic and reusable
|
||||||
|
const itemsWithNames = flatRows.map((row) => ({
|
||||||
|
...row,
|
||||||
|
names: [row.serieName, ...row.breakdownValues],
|
||||||
|
}));
|
||||||
|
|
||||||
|
return groupByNames(itemsWithNames);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transform series into grouped table rows (legacy flat format)
|
||||||
|
* Groups rows hierarchically by breakdown values
|
||||||
|
* @deprecated Use createGroupedRowsHierarchical + groupsToTableRows instead
|
||||||
|
*/
|
||||||
|
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)[] = [];
|
||||||
|
const firstRow = groupRows[0]!;
|
||||||
|
|
||||||
|
if (index === 0) {
|
||||||
|
// First row shows all breakdown values
|
||||||
|
breakdownDisplay.push(...row.breakdownValues);
|
||||||
|
} else {
|
||||||
|
// Subsequent rows: show all values, but mark duplicates for muted styling
|
||||||
|
for (let i = 0; i < row.breakdownValues.length; i++) {
|
||||||
|
// Always show the value, even if it matches the first row
|
||||||
|
breakdownDisplay.push(row.breakdownValues[i] ?? null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
const firstRow = groupRows[0]!;
|
||||||
|
|
||||||
|
// Aggregate metrics from all rows in the group
|
||||||
|
const totalSum = groupRows.reduce((sum, row) => sum + row.sum, 0);
|
||||||
|
const totalCount = groupRows.reduce((sum, row) => sum + row.count, 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 across all rows
|
||||||
|
const dateValues: Record<string, number> = {};
|
||||||
|
groupRows.forEach((row) => {
|
||||||
|
Object.keys(row.dateValues).forEach((date) => {
|
||||||
|
dateValues[date] = (dateValues[date] ?? 0) + row.dateValues[date];
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build breakdownDisplay: show first breakdown value, rest are null
|
||||||
|
const breakdownDisplay: (string | null)[] = [
|
||||||
|
firstRow.breakdownValues[0] ?? null,
|
||||||
|
...Array(breakdownCount - 1).fill(null),
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `summary-${groupKey}`,
|
||||||
|
serieId: firstRow.serieId,
|
||||||
|
serieName: firstRow.serieName,
|
||||||
|
breakdownValues: firstRow.breakdownValues,
|
||||||
|
count: totalCount,
|
||||||
|
sum: totalSum,
|
||||||
|
average: totalAverage,
|
||||||
|
min: totalMin,
|
||||||
|
max: totalMax,
|
||||||
|
dateValues,
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transform chart data into hierarchical groups
|
||||||
|
* Returns hierarchical structure for better group management
|
||||||
|
*/
|
||||||
|
export function transformToHierarchicalGroups(
|
||||||
|
data: IChartData,
|
||||||
|
breakdowns: Array<{ name: string }>,
|
||||||
|
): {
|
||||||
|
groups: Array<GroupedItem<TableRow>>;
|
||||||
|
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 groups = createGroupedRowsHierarchical(reorderedSeries, dates);
|
||||||
|
|
||||||
|
return {
|
||||||
|
groups,
|
||||||
|
dates,
|
||||||
|
breakdownPropertyNames,
|
||||||
|
};
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -2,9 +2,10 @@ import { pushModal } from '@/modals';
|
|||||||
import type { RouterOutputs } from '@/trpc/client';
|
import type { RouterOutputs } from '@/trpc/client';
|
||||||
import { cn } from '@/utils/cn';
|
import { cn } from '@/utils/cn';
|
||||||
import { getChartColor } from '@/utils/theme';
|
import { getChartColor } from '@/utils/theme';
|
||||||
import { useCallback } from 'react';
|
import React, { useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
CartesianGrid,
|
CartesianGrid,
|
||||||
|
Legend,
|
||||||
Line,
|
Line,
|
||||||
LineChart,
|
LineChart,
|
||||||
ReferenceLine,
|
ReferenceLine,
|
||||||
@@ -13,16 +14,25 @@ import {
|
|||||||
YAxis,
|
YAxis,
|
||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
|
|
||||||
import { createChartTooltip } from '@/components/charts/chart-tooltip';
|
import {
|
||||||
|
ChartTooltipHeader,
|
||||||
|
ChartTooltipItem,
|
||||||
|
createChartTooltip,
|
||||||
|
} from '@/components/charts/chart-tooltip';
|
||||||
|
import { useConversionRechartDataModel } from '@/hooks/use-conversion-rechart-data-model';
|
||||||
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 { useVisibleConversionSeries } from '@/hooks/use-visible-conversion-series';
|
||||||
import { useTRPC } from '@/integrations/trpc/react';
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
import { average, getPreviousMetric, round } from '@openpanel/common';
|
import { average, getPreviousMetric, round } from '@openpanel/common';
|
||||||
import type { IInterval } from '@openpanel/validation';
|
import type { IInterval } from '@openpanel/validation';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useXAxisProps, useYAxisProps } from '../common/axis';
|
import { useXAxisProps, useYAxisProps } from '../common/axis';
|
||||||
import { PreviousDiffIndicatorPure } from '../common/previous-diff-indicator';
|
import { PreviousDiffIndicator } from '../common/previous-diff-indicator';
|
||||||
|
import { SerieIcon } from '../common/serie-icon';
|
||||||
|
import { SerieName } from '../common/serie-name';
|
||||||
import { useReportChartContext } from '../context';
|
import { useReportChartContext } from '../context';
|
||||||
|
import { ConversionTable } from './conversion-table';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
data: RouterOutputs['chart']['conversion'];
|
data: RouterOutputs['chart']['conversion'];
|
||||||
@@ -30,20 +40,12 @@ 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();
|
||||||
const dataLength = data.current.length || 0;
|
const { series, setVisibleSeries } = useVisibleConversionSeries(data, 5);
|
||||||
|
const rechartData = useConversionRechartDataModel(series);
|
||||||
const trpc = useTRPC();
|
const trpc = useTRPC();
|
||||||
const references = useQuery(
|
const references = useQuery(
|
||||||
trpc.reference.getChartReferences.queryOptions(
|
trpc.reference.getChartReferences.queryOptions(
|
||||||
@@ -65,18 +67,11 @@ export function Chart({ data }: Props) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const averageConversionRate = average(
|
const averageConversionRate = average(
|
||||||
data.current.map((serie) => {
|
series.map((serie) => {
|
||||||
return average(serie.data.map((item) => item.rate));
|
return average(serie.data.map((item) => item.rate));
|
||||||
}, 0),
|
}, 0),
|
||||||
);
|
);
|
||||||
|
|
||||||
const rechartData = data.current[0].data.map((item) => {
|
|
||||||
return {
|
|
||||||
...item,
|
|
||||||
timestamp: new Date(item.date).getTime(),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleChartClick = useCallback((e: any) => {
|
const handleChartClick = useCallback((e: any) => {
|
||||||
if (e?.activePayload?.[0]) {
|
if (e?.activePayload?.[0]) {
|
||||||
const clickedData = e.activePayload[0].payload;
|
const clickedData = e.activePayload[0].payload;
|
||||||
@@ -88,8 +83,36 @@ export function Chart({ data }: Props) {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const CustomLegend = useCallback(() => {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap justify-center gap-x-4 gap-y-1 text-xs mt-4 -mb-2">
|
||||||
|
{series.map((serie) => (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-1"
|
||||||
|
key={serie.id}
|
||||||
|
style={{
|
||||||
|
color: getChartColor(serie.index),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SerieIcon name={serie.breakdowns} />
|
||||||
|
<SerieName
|
||||||
|
name={
|
||||||
|
serie.breakdowns.length > 0 ? serie.breakdowns : ['Conversion']
|
||||||
|
}
|
||||||
|
className="font-semibold"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}, [series]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider conversion={data} interval={interval}>
|
<TooltipProvider
|
||||||
|
conversion={data}
|
||||||
|
interval={interval}
|
||||||
|
visibleSeries={series}
|
||||||
|
>
|
||||||
<div className={cn('h-full w-full', isEditMode && 'card p-4')}>
|
<div className={cn('h-full w-full', isEditMode && 'card p-4')}>
|
||||||
<ResponsiveContainer>
|
<ResponsiveContainer>
|
||||||
<LineChart data={rechartData} onClick={handleChartClick}>
|
<LineChart data={rechartData} onClick={handleChartClick}>
|
||||||
@@ -116,35 +139,48 @@ export function Chart({ data }: Props) {
|
|||||||
))}
|
))}
|
||||||
<YAxis {...yAxisProps} domain={[0, 100]} />
|
<YAxis {...yAxisProps} domain={[0, 100]} />
|
||||||
<XAxis {...xAxisProps} allowDuplicatedCategory={false} />
|
<XAxis {...xAxisProps} allowDuplicatedCategory={false} />
|
||||||
|
{series.length > 1 && <Legend content={<CustomLegend />} />}
|
||||||
<Tooltip />
|
<Tooltip />
|
||||||
<Line
|
{series.map((serie) => {
|
||||||
dot={false}
|
const color = getChartColor(serie.index);
|
||||||
dataKey="previousRate"
|
return (
|
||||||
stroke={getChartColor(0)}
|
<Line
|
||||||
type={lineType}
|
key={`${serie.id}:previousRate`}
|
||||||
isAnimationActive={false}
|
dot={false}
|
||||||
strokeWidth={1}
|
dataKey={`${serie.id}:previousRate`}
|
||||||
strokeOpacity={0.5}
|
stroke={color}
|
||||||
/>
|
type={lineType}
|
||||||
<Line
|
isAnimationActive={false}
|
||||||
dataKey="rate"
|
strokeWidth={1}
|
||||||
stroke={getChartColor(0)}
|
strokeOpacity={0.3}
|
||||||
type={lineType}
|
/>
|
||||||
isAnimationActive={false}
|
);
|
||||||
strokeWidth={2}
|
})}
|
||||||
/>
|
{series.map((serie) => {
|
||||||
|
const color = getChartColor(serie.index);
|
||||||
|
return (
|
||||||
|
<Line
|
||||||
|
key={`${serie.id}:rate`}
|
||||||
|
dataKey={`${serie.id}:rate`}
|
||||||
|
stroke={color}
|
||||||
|
type={lineType}
|
||||||
|
isAnimationActive={false}
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
{typeof averageConversionRate === 'number' &&
|
{typeof averageConversionRate === 'number' &&
|
||||||
averageConversionRate && (
|
averageConversionRate && (
|
||||||
<ReferenceLine
|
<ReferenceLine
|
||||||
y={averageConversionRate}
|
y={averageConversionRate}
|
||||||
stroke={getChartColor(1)}
|
stroke={getChartColor(series.length)}
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
strokeDasharray="3 3"
|
strokeDasharray="3 3"
|
||||||
strokeOpacity={0.5}
|
strokeOpacity={0.5}
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
label={{
|
label={{
|
||||||
value: `Average (${round(averageConversionRate, 2)} %)`,
|
value: `Average (${round(averageConversionRate, 2)} %)`,
|
||||||
fill: getChartColor(1),
|
fill: getChartColor(series.length),
|
||||||
position: 'insideBottomRight',
|
position: 'insideBottomRight',
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
}}
|
}}
|
||||||
@@ -153,72 +189,92 @@ export function Chart({ data }: Props) {
|
|||||||
</LineChart>
|
</LineChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
|
<ConversionTable
|
||||||
|
data={data}
|
||||||
|
visibleSeries={series}
|
||||||
|
setVisibleSeries={setVisibleSeries}
|
||||||
|
/>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { Tooltip, TooltipProvider } = createChartTooltip<
|
const { Tooltip, TooltipProvider } = createChartTooltip<
|
||||||
NonNullable<
|
Record<string, any>,
|
||||||
RouterOutputs['chart']['conversion']['current'][number]
|
|
||||||
>['data'][number],
|
|
||||||
{
|
{
|
||||||
conversion: RouterOutputs['chart']['conversion'];
|
conversion: RouterOutputs['chart']['conversion'];
|
||||||
interval: IInterval;
|
interval: IInterval;
|
||||||
|
visibleSeries: RouterOutputs['chart']['conversion']['current'];
|
||||||
}
|
}
|
||||||
>(({ data, context }) => {
|
>(({ data, context }) => {
|
||||||
if (!data[0]) {
|
if (!data || !data[0]) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { date } = data[0];
|
const payload = data[0];
|
||||||
|
const { date } = payload;
|
||||||
const formatDate = useFormatDateInterval({
|
const formatDate = useFormatDateInterval({
|
||||||
interval: context.interval,
|
interval: context.interval,
|
||||||
short: false,
|
short: false,
|
||||||
});
|
});
|
||||||
const number = useNumber();
|
const number = useNumber();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex justify-between gap-8 text-muted-foreground">
|
{context.visibleSeries.map((serie, index) => {
|
||||||
<div>{formatDate(date)}</div>
|
const rate = payload[`${serie.id}:rate`];
|
||||||
</div>
|
const total = payload[`${serie.id}:total`];
|
||||||
{context.conversion.current.map((serie, index) => {
|
const previousRate = payload[`${serie.id}:previousRate`];
|
||||||
const item = data[index];
|
|
||||||
if (!item) {
|
if (rate === undefined) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const prevItem = context.conversion?.previous?.[0]?.data[item.index];
|
|
||||||
|
|
||||||
const title =
|
const prevSerie = context.conversion?.previous?.find(
|
||||||
serie.breakdowns.length > 0
|
(p) => p.id === serie.id,
|
||||||
? (serie.breakdowns.join(',') ?? 'Not set')
|
);
|
||||||
: 'Conversion';
|
const prevItem = prevSerie?.data.find((d) => d.date === date);
|
||||||
|
const previousMetric = getPreviousMetric(rate, previousRate);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="row gap-2" key={serie.id}>
|
<React.Fragment key={serie.id}>
|
||||||
<div
|
{index === 0 && (
|
||||||
className="w-[3px] rounded-full"
|
<ChartTooltipHeader>
|
||||||
style={{ background: getChartColor(index) }}
|
<div>{formatDate(date)}</div>
|
||||||
/>
|
</ChartTooltipHeader>
|
||||||
<div className="col flex-1 gap-1">
|
)}
|
||||||
<div className="flex items-center gap-1">{title}</div>
|
<ChartTooltipItem color={getChartColor(index)}>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<SerieIcon
|
||||||
|
name={
|
||||||
|
serie.breakdowns.length > 0
|
||||||
|
? serie.breakdowns
|
||||||
|
: ['Conversion']
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SerieName
|
||||||
|
name={
|
||||||
|
serie.breakdowns.length > 0
|
||||||
|
? serie.breakdowns
|
||||||
|
: ['Conversion']
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="flex justify-between gap-8 font-mono font-medium">
|
<div className="flex justify-between gap-8 font-mono font-medium">
|
||||||
<div className="col gap-1">
|
<div className="row gap-1">
|
||||||
<span>{number.formatWithUnit(item.rate / 100, '%')}</span>
|
<span>{number.formatWithUnit(rate / 100, '%')}</span>
|
||||||
<span>{item.total}</span>
|
<span className="text-muted-foreground">({total})</span>
|
||||||
</div>
|
{prevItem && previousRate !== undefined && (
|
||||||
|
|
||||||
{!!prevItem && (
|
|
||||||
<div className="col gap-1">
|
|
||||||
<PreviousDiffIndicatorPure
|
|
||||||
{...getPreviousMetric(item.rate, prevItem?.rate)}
|
|
||||||
/>
|
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
({prevItem?.total})
|
({number.formatWithUnit(previousRate / 100, '%')})
|
||||||
</span>
|
</span>
|
||||||
</div>
|
)}
|
||||||
|
</div>
|
||||||
|
{previousRate !== undefined && (
|
||||||
|
<PreviousDiffIndicator {...previousMetric} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ChartTooltipItem>
|
||||||
</div>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -0,0 +1,515 @@
|
|||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { useFormatDateInterval } from '@/hooks/use-format-date-interval';
|
||||||
|
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||||
|
import { useSelector } from '@/redux';
|
||||||
|
import type { RouterOutputs } from '@/trpc/client';
|
||||||
|
import { cn } from '@/utils/cn';
|
||||||
|
import { getChartColor } from '@/utils/theme';
|
||||||
|
import { getPreviousMetric } from '@openpanel/common';
|
||||||
|
import type { SortingState } from '@tanstack/react-table';
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { PreviousDiffIndicatorPure } from '../common/previous-diff-indicator';
|
||||||
|
import { ReportTableToolbar } from '../common/report-table-toolbar';
|
||||||
|
import { SerieIcon } from '../common/serie-icon';
|
||||||
|
import { SerieName } from '../common/serie-name';
|
||||||
|
|
||||||
|
interface ConversionTableProps {
|
||||||
|
data: RouterOutputs['chart']['conversion'];
|
||||||
|
visibleSeries: RouterOutputs['chart']['conversion']['current'];
|
||||||
|
setVisibleSeries: React.Dispatch<React.SetStateAction<string[]>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConversionTable({
|
||||||
|
data,
|
||||||
|
visibleSeries,
|
||||||
|
setVisibleSeries,
|
||||||
|
}: ConversionTableProps) {
|
||||||
|
const [sorting, setSorting] = useState<SortingState>([]);
|
||||||
|
const [globalFilter, setGlobalFilter] = useState('');
|
||||||
|
const number = useNumber();
|
||||||
|
const interval = useSelector((state) => state.report.interval);
|
||||||
|
const formatDate = useFormatDateInterval({
|
||||||
|
interval,
|
||||||
|
short: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get all unique dates from the first series
|
||||||
|
const dates = useMemo(
|
||||||
|
() => data.current[0]?.data.map((item) => item.date) ?? [],
|
||||||
|
[data.current],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get all series (including non-visible ones for toggle functionality)
|
||||||
|
const allSeries = data.current;
|
||||||
|
|
||||||
|
// Transform data to table rows with memoization
|
||||||
|
const rows = useMemo(() => {
|
||||||
|
return allSeries.map((serie) => {
|
||||||
|
const dateValues: Record<string, number> = {};
|
||||||
|
dates.forEach((date) => {
|
||||||
|
const item = serie.data.find((d) => d.date === date);
|
||||||
|
dateValues[date] = item?.rate ?? 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
const total = serie.data.reduce((sum, item) => sum + item.total, 0);
|
||||||
|
const conversions = serie.data.reduce(
|
||||||
|
(sum, item) => sum + item.conversions,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const avgRate =
|
||||||
|
serie.data.length > 0
|
||||||
|
? serie.data.reduce((sum, item) => sum + item.rate, 0) /
|
||||||
|
serie.data.length
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const prevSerie = data.previous?.find((p) => p.id === serie.id);
|
||||||
|
const prevAvgRate =
|
||||||
|
prevSerie && prevSerie.data.length > 0
|
||||||
|
? prevSerie.data.reduce((sum, item) => sum + item.rate, 0) /
|
||||||
|
prevSerie.data.length
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: serie.id,
|
||||||
|
serieId: serie.id,
|
||||||
|
serieName:
|
||||||
|
serie.breakdowns.length > 0 ? serie.breakdowns : ['Conversion'],
|
||||||
|
breakdownValues: serie.breakdowns,
|
||||||
|
avgRate,
|
||||||
|
prevAvgRate,
|
||||||
|
total,
|
||||||
|
conversions,
|
||||||
|
dateValues,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [allSeries, dates, data.previous]);
|
||||||
|
|
||||||
|
// Calculate ranges for color visualization (memoized)
|
||||||
|
const { metricRanges, dateRanges } = useMemo(() => {
|
||||||
|
const metricRanges: Record<string, { min: number; max: number }> = {
|
||||||
|
avgRate: {
|
||||||
|
min: Number.POSITIVE_INFINITY,
|
||||||
|
max: Number.NEGATIVE_INFINITY,
|
||||||
|
},
|
||||||
|
total: {
|
||||||
|
min: Number.POSITIVE_INFINITY,
|
||||||
|
max: Number.NEGATIVE_INFINITY,
|
||||||
|
},
|
||||||
|
conversions: {
|
||||||
|
min: Number.POSITIVE_INFINITY,
|
||||||
|
max: Number.NEGATIVE_INFINITY,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const dateRanges: Record<string, { min: number; max: number }> = {};
|
||||||
|
dates.forEach((date) => {
|
||||||
|
dateRanges[date] = {
|
||||||
|
min: Number.POSITIVE_INFINITY,
|
||||||
|
max: Number.NEGATIVE_INFINITY,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
rows.forEach((row) => {
|
||||||
|
// Metric ranges
|
||||||
|
metricRanges.avgRate.min = Math.min(
|
||||||
|
metricRanges.avgRate.min,
|
||||||
|
row.avgRate,
|
||||||
|
);
|
||||||
|
metricRanges.avgRate.max = Math.max(
|
||||||
|
metricRanges.avgRate.max,
|
||||||
|
row.avgRate,
|
||||||
|
);
|
||||||
|
metricRanges.total.min = Math.min(metricRanges.total.min, row.total);
|
||||||
|
metricRanges.total.max = Math.max(metricRanges.total.max, row.total);
|
||||||
|
metricRanges.conversions.min = Math.min(
|
||||||
|
metricRanges.conversions.min,
|
||||||
|
row.conversions,
|
||||||
|
);
|
||||||
|
metricRanges.conversions.max = Math.max(
|
||||||
|
metricRanges.conversions.max,
|
||||||
|
row.conversions,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Date ranges
|
||||||
|
dates.forEach((date) => {
|
||||||
|
const value = row.dateValues[date] ?? 0;
|
||||||
|
dateRanges[date]!.min = Math.min(dateRanges[date]!.min, value);
|
||||||
|
dateRanges[date]!.max = Math.max(dateRanges[date]!.max, value);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return { metricRanges, dateRanges };
|
||||||
|
}, [rows, dates]);
|
||||||
|
|
||||||
|
// Helper to get background color style
|
||||||
|
const getCellBackgroundStyle = (
|
||||||
|
value: number,
|
||||||
|
min: number,
|
||||||
|
max: number,
|
||||||
|
colorClass: 'purple' | 'emerald' = 'emerald',
|
||||||
|
): React.CSSProperties => {
|
||||||
|
if (value === 0 || max === min) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const percentage = (value - min) / (max - min);
|
||||||
|
const opacity = Math.max(0.05, Math.min(1, percentage));
|
||||||
|
|
||||||
|
const backgroundColor =
|
||||||
|
colorClass === 'purple'
|
||||||
|
? `rgba(168, 85, 247, ${opacity})`
|
||||||
|
: `rgba(16, 185, 129, ${opacity})`;
|
||||||
|
|
||||||
|
return { backgroundColor };
|
||||||
|
};
|
||||||
|
|
||||||
|
const visibleSeriesIds = useMemo(
|
||||||
|
() => visibleSeries.map((s) => s.id),
|
||||||
|
[visibleSeries],
|
||||||
|
);
|
||||||
|
|
||||||
|
const getSerieIndex = (serieId: string): number => {
|
||||||
|
return allSeries.findIndex((s) => s.id === serieId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleSerieVisibility = (serieId: string) => {
|
||||||
|
setVisibleSeries((prev) => {
|
||||||
|
if (prev.includes(serieId)) {
|
||||||
|
return prev.filter((id) => id !== serieId);
|
||||||
|
}
|
||||||
|
return [...prev, serieId];
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filter and sort rows
|
||||||
|
const filteredAndSortedRows = useMemo(() => {
|
||||||
|
let result = rows;
|
||||||
|
|
||||||
|
// Apply search filter
|
||||||
|
if (globalFilter.trim()) {
|
||||||
|
const searchLower = globalFilter.toLowerCase();
|
||||||
|
result = rows.filter((row) => {
|
||||||
|
// Search in serie name
|
||||||
|
if (
|
||||||
|
row.serieName.some((name) =>
|
||||||
|
name?.toLowerCase().includes(searchLower),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search in breakdown values
|
||||||
|
if (
|
||||||
|
row.breakdownValues.some((val) =>
|
||||||
|
val?.toLowerCase().includes(searchLower),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search in metric values
|
||||||
|
if (
|
||||||
|
String(row.avgRate).toLowerCase().includes(searchLower) ||
|
||||||
|
String(row.total).toLowerCase().includes(searchLower) ||
|
||||||
|
String(row.conversions).toLowerCase().includes(searchLower)
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search in date values
|
||||||
|
if (
|
||||||
|
Object.values(row.dateValues).some((val) =>
|
||||||
|
String(val).toLowerCase().includes(searchLower),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply sorting
|
||||||
|
if (sorting.length > 0) {
|
||||||
|
result = [...result].sort((a, b) => {
|
||||||
|
for (const sort of sorting) {
|
||||||
|
const { id, desc } = sort;
|
||||||
|
let aValue: any;
|
||||||
|
let bValue: any;
|
||||||
|
|
||||||
|
if (id === 'serie-name') {
|
||||||
|
aValue = a.serieName.join(' > ') ?? '';
|
||||||
|
bValue = b.serieName.join(' > ') ?? '';
|
||||||
|
} else if (id === 'metric-avgRate') {
|
||||||
|
aValue = a.avgRate ?? 0;
|
||||||
|
bValue = b.avgRate ?? 0;
|
||||||
|
} else if (id === 'metric-total') {
|
||||||
|
aValue = a.total ?? 0;
|
||||||
|
bValue = b.total ?? 0;
|
||||||
|
} else if (id === 'metric-conversions') {
|
||||||
|
aValue = a.conversions ?? 0;
|
||||||
|
bValue = b.conversions ?? 0;
|
||||||
|
} else if (id.startsWith('date-')) {
|
||||||
|
const date = id.replace('date-', '');
|
||||||
|
aValue = a.dateValues[date] ?? 0;
|
||||||
|
bValue = b.dateValues[date] ?? 0;
|
||||||
|
} else {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle null/undefined values
|
||||||
|
if (aValue == null && bValue == null) continue;
|
||||||
|
if (aValue == null) return 1;
|
||||||
|
if (bValue == null) return -1;
|
||||||
|
|
||||||
|
// Compare values
|
||||||
|
if (typeof aValue === 'string' && typeof bValue === 'string') {
|
||||||
|
const comparison = aValue.localeCompare(bValue);
|
||||||
|
if (comparison !== 0) return desc ? -comparison : comparison;
|
||||||
|
} else {
|
||||||
|
if (aValue < bValue) return desc ? 1 : -1;
|
||||||
|
if (aValue > bValue) return desc ? -1 : 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, [rows, globalFilter, sorting]);
|
||||||
|
|
||||||
|
const handleSort = (columnId: string) => {
|
||||||
|
setSorting((prev) => {
|
||||||
|
const existingSort = prev.find((s) => s.id === columnId);
|
||||||
|
if (existingSort) {
|
||||||
|
if (existingSort.desc) {
|
||||||
|
// Toggle to ascending if already descending
|
||||||
|
return [{ id: columnId, desc: false }];
|
||||||
|
}
|
||||||
|
// Remove sort if already ascending
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
// Start with descending (highest first)
|
||||||
|
return [{ id: columnId, desc: true }];
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSortIcon = (columnId: string) => {
|
||||||
|
const sort = sorting.find((s) => s.id === columnId);
|
||||||
|
if (!sort) return '⇅';
|
||||||
|
return sort.desc ? '↓' : '↑';
|
||||||
|
};
|
||||||
|
|
||||||
|
if (allSeries.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col border rounded-lg overflow-hidden bg-card mt-8">
|
||||||
|
<ReportTableToolbar
|
||||||
|
search={globalFilter}
|
||||||
|
onSearchChange={setGlobalFilter}
|
||||||
|
onUnselectAll={() => setVisibleSeries([])}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="overflow-x-auto overflow-y-auto"
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
maxHeight: '600px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<table className="w-full" style={{ minWidth: 'fit-content' }}>
|
||||||
|
<thead className="bg-muted/30 border-b sticky top-0 z-10">
|
||||||
|
<tr>
|
||||||
|
<th
|
||||||
|
className="text-left h-10 px-4 text-[10px] uppercase font-semibold sticky left-0 bg-card z-20 min-w-[200px] border-r border-border whitespace-nowrap"
|
||||||
|
style={{
|
||||||
|
boxShadow: '2px 0 4px -2px var(--border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center">Serie</div>
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
className="text-right h-10 px-4 text-[10px] uppercase font-semibold min-w-[100px] cursor-pointer hover:bg-muted/50 select-none border-r border-border whitespace-nowrap"
|
||||||
|
onClick={() => handleSort('metric-avgRate')}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSort('metric-avgRate');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-end gap-1.5">
|
||||||
|
Avg Rate
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{getSortIcon('metric-avgRate')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
className="text-right h-10 px-4 text-[10px] uppercase font-semibold min-w-[100px] cursor-pointer hover:bg-muted/50 select-none border-r border-border whitespace-nowrap"
|
||||||
|
onClick={() => handleSort('metric-total')}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSort('metric-total');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-end gap-1.5">
|
||||||
|
Total
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{getSortIcon('metric-total')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
className="text-right h-10 px-4 text-[10px] uppercase font-semibold min-w-[100px] cursor-pointer hover:bg-muted/50 select-none border-r border-border whitespace-nowrap"
|
||||||
|
onClick={() => handleSort('metric-conversions')}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSort('metric-conversions');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-end gap-1.5">
|
||||||
|
Conversions
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{getSortIcon('metric-conversions')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
{dates.map((date) => (
|
||||||
|
<th
|
||||||
|
key={date}
|
||||||
|
className="text-right h-10 px-4 text-[10px] uppercase font-semibold min-w-[100px] cursor-pointer hover:bg-muted/50 select-none border-r border-border whitespace-nowrap"
|
||||||
|
onClick={() => handleSort(`date-${date}`)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSort(`date-${date}`);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-end gap-1.5">
|
||||||
|
{formatDate(date)}
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{getSortIcon(`date-${date}`)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filteredAndSortedRows.map((row) => {
|
||||||
|
const isVisible = visibleSeriesIds.includes(row.serieId);
|
||||||
|
const serieIndex = getSerieIndex(row.serieId);
|
||||||
|
const color = getChartColor(serieIndex);
|
||||||
|
const previousMetric =
|
||||||
|
row.prevAvgRate !== undefined
|
||||||
|
? getPreviousMetric(row.avgRate, row.prevAvgRate)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={row.id}
|
||||||
|
className={cn(
|
||||||
|
'border-b hover:bg-muted/30 transition-colors',
|
||||||
|
!isVisible && 'opacity-50',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<td
|
||||||
|
className="px-4 py-3 sticky left-0 z-10 border-r border-border"
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--card)',
|
||||||
|
boxShadow: '2px 0 4px -2px var(--border)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
checked={isVisible}
|
||||||
|
onCheckedChange={() =>
|
||||||
|
toggleSerieVisibility(row.serieId)
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
borderColor: color,
|
||||||
|
backgroundColor: isVisible ? color : 'transparent',
|
||||||
|
}}
|
||||||
|
className="h-4 w-4 shrink-0"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="w-[3px] rounded-full shrink-0"
|
||||||
|
style={{ background: color }}
|
||||||
|
/>
|
||||||
|
<SerieIcon name={row.serieName} />
|
||||||
|
<SerieName name={row.serieName} className="truncate" />
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className="px-4 py-3 text-right font-mono text-sm"
|
||||||
|
style={getCellBackgroundStyle(
|
||||||
|
row.avgRate,
|
||||||
|
metricRanges.avgRate.min,
|
||||||
|
metricRanges.avgRate.max,
|
||||||
|
'purple',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<span>
|
||||||
|
{number.formatWithUnit(row.avgRate / 100, '%')}
|
||||||
|
</span>
|
||||||
|
{previousMetric && (
|
||||||
|
<PreviousDiffIndicatorPure {...previousMetric} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className="px-4 py-3 text-right font-mono text-sm"
|
||||||
|
style={getCellBackgroundStyle(
|
||||||
|
row.total,
|
||||||
|
metricRanges.total.min,
|
||||||
|
metricRanges.total.max,
|
||||||
|
'purple',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{number.format(row.total)}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className="px-4 py-3 text-right font-mono text-sm"
|
||||||
|
style={getCellBackgroundStyle(
|
||||||
|
row.conversions,
|
||||||
|
metricRanges.conversions.min,
|
||||||
|
metricRanges.conversions.max,
|
||||||
|
'purple',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{number.format(row.conversions)}
|
||||||
|
</td>
|
||||||
|
{dates.map((date) => {
|
||||||
|
const value = row.dateValues[date] ?? 0;
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
key={date}
|
||||||
|
className="px-4 py-3 text-right font-mono text-sm"
|
||||||
|
style={getCellBackgroundStyle(
|
||||||
|
value,
|
||||||
|
dateRanges[date]!.min,
|
||||||
|
dateRanges[date]!.max,
|
||||||
|
'emerald',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{number.formatWithUnit(value / 100, '%')}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ import { Summary } from './summary';
|
|||||||
export function ReportConversionChart() {
|
export function ReportConversionChart() {
|
||||||
const { isLazyLoading, report } = useReportChartContext();
|
const { isLazyLoading, report } = useReportChartContext();
|
||||||
const trpc = useTRPC();
|
const trpc = useTRPC();
|
||||||
|
console.log(report.limit);
|
||||||
const res = useQuery(
|
const res = useQuery(
|
||||||
trpc.chart.conversion.queryOptions(report, {
|
trpc.chart.conversion.queryOptions(report, {
|
||||||
placeholderData: keepPreviousData,
|
placeholderData: keepPreviousData,
|
||||||
|
|||||||
@@ -144,14 +144,16 @@ export function Summary({ data }: Props) {
|
|||||||
title="Flow"
|
title="Flow"
|
||||||
value={
|
value={
|
||||||
<div className="row flex-wrap gap-1">
|
<div className="row flex-wrap gap-1">
|
||||||
{report.events.map((event, index) => {
|
{report.series
|
||||||
return (
|
.filter((item) => item.type === 'event')
|
||||||
<div key={event.id} className="row items-center gap-2">
|
.map((event, index) => {
|
||||||
{index !== 0 && <ChevronRightIcon className="size-3" />}
|
return (
|
||||||
<span>{event.name}</span>
|
<div key={event.id} className="row items-center gap-2">
|
||||||
</div>
|
{index !== 0 && <ChevronRightIcon className="size-3" />}
|
||||||
);
|
<span>{event.name}</span>
|
||||||
})}
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { ColorSquare } from '@/components/color-square';
|
import { ColorSquare } from '@/components/color-square';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { pushModal } from '@/modals';
|
||||||
import type { RouterOutputs } from '@/trpc/client';
|
import type { RouterOutputs } from '@/trpc/client';
|
||||||
import { cn } from '@/utils/cn';
|
import { cn } from '@/utils/cn';
|
||||||
import { ChevronRightIcon, InfoIcon } from 'lucide-react';
|
import { ChevronRightIcon, InfoIcon, UsersIcon } from 'lucide-react';
|
||||||
|
|
||||||
import { alphabetIds } from '@openpanel/constants';
|
import { alphabetIds } from '@openpanel/constants';
|
||||||
|
|
||||||
@@ -23,6 +25,7 @@ import {
|
|||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
import { useXAxisProps, useYAxisProps } from '../common/axis';
|
import { useXAxisProps, useYAxisProps } from '../common/axis';
|
||||||
import { PreviousDiffIndicatorPure } from '../common/previous-diff-indicator';
|
import { PreviousDiffIndicatorPure } from '../common/previous-diff-indicator';
|
||||||
|
import { useReportChartContext } from '../context';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
data: {
|
data: {
|
||||||
@@ -113,11 +116,50 @@ function ChartName({
|
|||||||
export function Tables({
|
export function Tables({
|
||||||
data: {
|
data: {
|
||||||
current: { steps, mostDropoffsStep, lastStep, breakdowns },
|
current: { steps, mostDropoffsStep, lastStep, breakdowns },
|
||||||
previous,
|
previous: previousData,
|
||||||
},
|
},
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const number = useNumber();
|
const number = useNumber();
|
||||||
const hasHeader = breakdowns.length > 0;
|
const hasHeader = breakdowns.length > 0;
|
||||||
|
const {
|
||||||
|
report: {
|
||||||
|
projectId,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
range,
|
||||||
|
interval,
|
||||||
|
series: reportSeries,
|
||||||
|
breakdowns: reportBreakdowns,
|
||||||
|
previous,
|
||||||
|
funnelWindow,
|
||||||
|
funnelGroup,
|
||||||
|
},
|
||||||
|
} = useReportChartContext();
|
||||||
|
|
||||||
|
const handleInspectStep = (step: (typeof steps)[0], stepIndex: number) => {
|
||||||
|
if (!projectId || !step.event.id) return;
|
||||||
|
|
||||||
|
// For funnels, we need to pass the step index so the modal can query
|
||||||
|
// users who completed at least that step in the funnel sequence
|
||||||
|
pushModal('ViewChartUsers', {
|
||||||
|
type: 'funnel',
|
||||||
|
report: {
|
||||||
|
projectId,
|
||||||
|
series: reportSeries,
|
||||||
|
breakdowns: reportBreakdowns || [],
|
||||||
|
interval: interval || 'day',
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
range,
|
||||||
|
previous,
|
||||||
|
chartType: 'funnel',
|
||||||
|
metric: 'sum',
|
||||||
|
funnelWindow,
|
||||||
|
funnelGroup,
|
||||||
|
},
|
||||||
|
stepIndex, // Pass the step index for funnel queries
|
||||||
|
});
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<div className={cn('col @container divide-y divide-border card')}>
|
<div className={cn('col @container divide-y divide-border card')}>
|
||||||
{hasHeader && <ChartName breakdowns={breakdowns} className="p-4 py-3" />}
|
{hasHeader && <ChartName breakdowns={breakdowns} className="p-4 py-3" />}
|
||||||
@@ -128,11 +170,11 @@ export function Tables({
|
|||||||
label="Conversion"
|
label="Conversion"
|
||||||
value={number.formatWithUnit(lastStep?.percent / 100, '%')}
|
value={number.formatWithUnit(lastStep?.percent / 100, '%')}
|
||||||
enhancer={
|
enhancer={
|
||||||
previous && (
|
previousData && (
|
||||||
<PreviousDiffIndicatorPure
|
<PreviousDiffIndicatorPure
|
||||||
{...getPreviousMetric(
|
{...getPreviousMetric(
|
||||||
lastStep?.percent,
|
lastStep?.percent,
|
||||||
previous.lastStep?.percent,
|
previousData.lastStep?.percent,
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -143,11 +185,11 @@ export function Tables({
|
|||||||
label="Completed"
|
label="Completed"
|
||||||
value={number.format(lastStep?.count)}
|
value={number.format(lastStep?.count)}
|
||||||
enhancer={
|
enhancer={
|
||||||
previous && (
|
previousData && (
|
||||||
<PreviousDiffIndicatorPure
|
<PreviousDiffIndicatorPure
|
||||||
{...getPreviousMetric(
|
{...getPreviousMetric(
|
||||||
lastStep?.count,
|
lastStep?.count,
|
||||||
previous.lastStep?.count,
|
previousData.lastStep?.count,
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -238,6 +280,28 @@ export function Tables({
|
|||||||
className: 'text-right font-mono font-semibold',
|
className: 'text-right font-mono font-semibold',
|
||||||
width: '90px',
|
width: '90px',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: '',
|
||||||
|
render: (item) => (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const stepIndex = steps.findIndex(
|
||||||
|
(s) => s.event.id === item.event.id,
|
||||||
|
);
|
||||||
|
handleInspectStep(item, stepIndex);
|
||||||
|
}}
|
||||||
|
title="View users who completed this step"
|
||||||
|
>
|
||||||
|
<UsersIcon size={16} />
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
className: 'text-right',
|
||||||
|
width: '48px',
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -299,6 +363,7 @@ export function Chart({ data }: { data: RouterOutputs['chart']['funnel'] }) {
|
|||||||
const rechartData = useRechartData(data);
|
const rechartData = useRechartData(data);
|
||||||
const xAxisProps = useXAxisProps();
|
const xAxisProps = useXAxisProps();
|
||||||
const yAxisProps = useYAxisProps();
|
const yAxisProps = useYAxisProps();
|
||||||
|
const hasBreakdowns = data.current.length > 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider data={data.current}>
|
<TooltipProvider data={data.current}>
|
||||||
@@ -327,19 +392,37 @@ export function Chart({ data }: { data: RouterOutputs['chart']['funnel'] }) {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<YAxis {...yAxisProps} />
|
<YAxis {...yAxisProps} />
|
||||||
<Bar
|
{hasBreakdowns ? (
|
||||||
data={rechartData}
|
data.current.map((item, breakdownIndex) => (
|
||||||
dataKey="step:percent:0"
|
<Bar
|
||||||
shape={<BarShapeProps />}
|
key={`step:percent:${item.id}`}
|
||||||
>
|
dataKey={`step:percent:${breakdownIndex}`}
|
||||||
{rechartData.map((item, index) => (
|
shape={<BarShapeProps />}
|
||||||
<Cell
|
>
|
||||||
key={item.name}
|
{rechartData.map((item, stepIndex) => (
|
||||||
fill={getChartTranslucentColor(index)}
|
<Cell
|
||||||
stroke={getChartColor(index)}
|
key={`${item.name}-${breakdownIndex}`}
|
||||||
/>
|
fill={getChartTranslucentColor(breakdownIndex)}
|
||||||
))}
|
stroke={getChartColor(breakdownIndex)}
|
||||||
</Bar>
|
/>
|
||||||
|
))}
|
||||||
|
</Bar>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<Bar
|
||||||
|
data={rechartData}
|
||||||
|
dataKey="step:percent:0"
|
||||||
|
shape={<BarShapeProps />}
|
||||||
|
>
|
||||||
|
{rechartData.map((item, index) => (
|
||||||
|
<Cell
|
||||||
|
key={item.name}
|
||||||
|
fill={getChartTranslucentColor(index)}
|
||||||
|
stroke={getChartColor(index)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Bar>
|
||||||
|
)}
|
||||||
<Tooltip />
|
<Tooltip />
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
@@ -348,8 +431,6 @@ export function Chart({ data }: { data: RouterOutputs['chart']['funnel'] }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type Hej = RouterOutputs['chart']['funnel']['current'];
|
|
||||||
|
|
||||||
const { Tooltip, TooltipProvider } = createChartTooltip<
|
const { Tooltip, TooltipProvider } = createChartTooltip<
|
||||||
RechartData,
|
RechartData,
|
||||||
{
|
{
|
||||||
@@ -371,7 +452,7 @@ const { Tooltip, TooltipProvider } = createChartTooltip<
|
|||||||
<div className="flex justify-between gap-8 text-muted-foreground">
|
<div className="flex justify-between gap-8 text-muted-foreground">
|
||||||
<div>{data.name}</div>
|
<div>{data.name}</div>
|
||||||
</div>
|
</div>
|
||||||
{variants.map((key) => {
|
{variants.map((key, breakdownIndex) => {
|
||||||
const variant = data[key];
|
const variant = data[key];
|
||||||
const prevVariant = data[`prev_${key}`];
|
const prevVariant = data[`prev_${key}`];
|
||||||
if (!variant?.step) {
|
if (!variant?.step) {
|
||||||
@@ -381,7 +462,11 @@ const { Tooltip, TooltipProvider } = createChartTooltip<
|
|||||||
<div className="row gap-2" key={key}>
|
<div className="row gap-2" key={key}>
|
||||||
<div
|
<div
|
||||||
className="w-[3px] rounded-full"
|
className="w-[3px] rounded-full"
|
||||||
style={{ background: getChartColor(index) }}
|
style={{
|
||||||
|
background: getChartColor(
|
||||||
|
variants.length > 1 ? breakdownIndex : index,
|
||||||
|
),
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="col flex-1 gap-1">
|
<div className="col flex-1 gap-1">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { Chart, Summary, Tables } from './chart';
|
|||||||
export function ReportFunnelChart() {
|
export function ReportFunnelChart() {
|
||||||
const {
|
const {
|
||||||
report: {
|
report: {
|
||||||
events,
|
series,
|
||||||
range,
|
range,
|
||||||
projectId,
|
projectId,
|
||||||
funnelWindow,
|
funnelWindow,
|
||||||
@@ -28,7 +28,7 @@ export function ReportFunnelChart() {
|
|||||||
} = useReportChartContext();
|
} = useReportChartContext();
|
||||||
|
|
||||||
const input: IChartInput = {
|
const input: IChartInput = {
|
||||||
events,
|
series,
|
||||||
range,
|
range,
|
||||||
projectId,
|
projectId,
|
||||||
interval: 'day',
|
interval: 'day',
|
||||||
@@ -40,11 +40,12 @@ export function ReportFunnelChart() {
|
|||||||
metric: 'sum',
|
metric: 'sum',
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
|
limit: 20,
|
||||||
};
|
};
|
||||||
const trpc = useTRPC();
|
const trpc = useTRPC();
|
||||||
const res = useQuery(
|
const res = useQuery(
|
||||||
trpc.chart.funnel.queryOptions(input, {
|
trpc.chart.funnel.queryOptions(input, {
|
||||||
enabled: !isLazyLoading && input.events.length > 0,
|
enabled: !isLazyLoading && input.series.length > 0,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type { IChartData } from '@/trpc/client';
|
|||||||
import { cn } from '@/utils/cn';
|
import { cn } from '@/utils/cn';
|
||||||
import { getChartColor } from '@/utils/theme';
|
import { getChartColor } from '@/utils/theme';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { BookmarkIcon, UsersIcon } from 'lucide-react';
|
||||||
import React, { useCallback } from 'react';
|
import React, { useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
Bar,
|
Bar,
|
||||||
@@ -20,6 +21,10 @@ import {
|
|||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
|
|
||||||
import { useXAxisProps, useYAxisProps } from '../common/axis';
|
import { useXAxisProps, useYAxisProps } from '../common/axis';
|
||||||
|
import {
|
||||||
|
ChartClickMenu,
|
||||||
|
type ChartClickMenuItem,
|
||||||
|
} from '../common/chart-click-menu';
|
||||||
import { ReportChartTooltip } from '../common/report-chart-tooltip';
|
import { ReportChartTooltip } from '../common/report-chart-tooltip';
|
||||||
import { ReportTable } from '../common/report-table';
|
import { ReportTable } from '../common/report-table';
|
||||||
import { useReportChartContext } from '../context';
|
import { useReportChartContext } from '../context';
|
||||||
@@ -47,7 +52,16 @@ function BarHover({ x, y, width, height, top, left, right, bottom }: any) {
|
|||||||
export function Chart({ data }: Props) {
|
export function Chart({ data }: Props) {
|
||||||
const {
|
const {
|
||||||
isEditMode,
|
isEditMode,
|
||||||
report: { previous, interval, projectId, startDate, endDate, range },
|
report: {
|
||||||
|
previous,
|
||||||
|
interval,
|
||||||
|
projectId,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
range,
|
||||||
|
series: reportSeries,
|
||||||
|
breakdowns,
|
||||||
|
},
|
||||||
options: { hideXAxis, hideYAxis },
|
options: { hideXAxis, hideYAxis },
|
||||||
} = useReportChartContext();
|
} = useReportChartContext();
|
||||||
const trpc = useTRPC();
|
const trpc = useTRPC();
|
||||||
@@ -74,22 +88,73 @@ export function Chart({ data }: Props) {
|
|||||||
interval,
|
interval,
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleChartClick = useCallback((e: any) => {
|
const getMenuItems = useCallback(
|
||||||
if (e?.activePayload?.[0]) {
|
(e: any, clickedData: any): ChartClickMenuItem[] => {
|
||||||
const clickedData = e.activePayload[0].payload;
|
const items: ChartClickMenuItem[] = [];
|
||||||
if (clickedData.date) {
|
|
||||||
pushModal('AddReference', {
|
if (!clickedData?.date) {
|
||||||
datetime: new Date(clickedData.date).toISOString(),
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
// View Users - only show if we have projectId
|
||||||
|
if (projectId) {
|
||||||
|
items.push({
|
||||||
|
label: 'View Users',
|
||||||
|
icon: <UsersIcon size={16} />,
|
||||||
|
onClick: () => {
|
||||||
|
pushModal('ViewChartUsers', {
|
||||||
|
type: 'chart',
|
||||||
|
chartData: data,
|
||||||
|
report: {
|
||||||
|
projectId,
|
||||||
|
series: reportSeries,
|
||||||
|
breakdowns: breakdowns || [],
|
||||||
|
interval,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
range,
|
||||||
|
previous,
|
||||||
|
chartType: 'histogram',
|
||||||
|
metric: 'sum',
|
||||||
|
},
|
||||||
|
date: clickedData.date,
|
||||||
|
});
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}, []);
|
// Add Reference - always show
|
||||||
|
items.push({
|
||||||
|
label: 'Add Reference',
|
||||||
|
icon: <BookmarkIcon size={16} />,
|
||||||
|
onClick: () => {
|
||||||
|
pushModal('AddReference', {
|
||||||
|
datetime: new Date(clickedData.date).toISOString(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return items;
|
||||||
|
},
|
||||||
|
[
|
||||||
|
projectId,
|
||||||
|
data,
|
||||||
|
reportSeries,
|
||||||
|
breakdowns,
|
||||||
|
interval,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
range,
|
||||||
|
previous,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ReportChartTooltip.TooltipProvider references={references.data}>
|
<ReportChartTooltip.TooltipProvider references={references.data}>
|
||||||
<div className={cn('h-full w-full', isEditMode && 'card p-4')}>
|
<ChartClickMenu getMenuItems={getMenuItems}>
|
||||||
<ResponsiveContainer>
|
<div className={cn('h-full w-full', isEditMode && 'card p-4')}>
|
||||||
<BarChart data={rechartData} onClick={handleChartClick}>
|
<ResponsiveContainer>
|
||||||
|
<BarChart data={rechartData}>
|
||||||
<CartesianGrid
|
<CartesianGrid
|
||||||
strokeDasharray="3 3"
|
strokeDasharray="3 3"
|
||||||
vertical={false}
|
vertical={false}
|
||||||
@@ -152,6 +217,7 @@ export function Chart({ data }: Props) {
|
|||||||
setVisibleSeries={setVisibleSeries}
|
setVisibleSeries={setVisibleSeries}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
</ChartClickMenu>
|
||||||
</ReportChartTooltip.TooltipProvider>
|
</ReportChartTooltip.TooltipProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { cn } from '@/utils/cn';
|
|||||||
import { getChartColor } from '@/utils/theme';
|
import { getChartColor } from '@/utils/theme';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { isSameDay, isSameHour, isSameMonth, isSameWeek } from 'date-fns';
|
import { isSameDay, isSameHour, isSameMonth, isSameWeek } from 'date-fns';
|
||||||
|
import { BookmarkIcon, UsersIcon } from 'lucide-react';
|
||||||
import { last } from 'ramda';
|
import { last } from 'ramda';
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
@@ -24,6 +25,10 @@ import {
|
|||||||
|
|
||||||
import { useDashedStroke } from '@/hooks/use-dashed-stroke';
|
import { useDashedStroke } from '@/hooks/use-dashed-stroke';
|
||||||
import { useXAxisProps, useYAxisProps } from '../common/axis';
|
import { useXAxisProps, useYAxisProps } from '../common/axis';
|
||||||
|
import {
|
||||||
|
ChartClickMenu,
|
||||||
|
type ChartClickMenuItem,
|
||||||
|
} from '../common/chart-click-menu';
|
||||||
import { ReportChartTooltip } from '../common/report-chart-tooltip';
|
import { ReportChartTooltip } from '../common/report-chart-tooltip';
|
||||||
import { ReportTable } from '../common/report-table';
|
import { ReportTable } from '../common/report-table';
|
||||||
import { SerieIcon } from '../common/serie-icon';
|
import { SerieIcon } from '../common/serie-icon';
|
||||||
@@ -44,6 +49,8 @@ export function Chart({ data }: Props) {
|
|||||||
endDate,
|
endDate,
|
||||||
range,
|
range,
|
||||||
lineType,
|
lineType,
|
||||||
|
series: reportSeries,
|
||||||
|
breakdowns,
|
||||||
},
|
},
|
||||||
isEditMode,
|
isEditMode,
|
||||||
options: { hideXAxis, hideYAxis, maxDomain },
|
options: { hideXAxis, hideYAxis, maxDomain },
|
||||||
@@ -128,81 +135,146 @@ export function Chart({ data }: Props) {
|
|||||||
hide: hideYAxis,
|
hide: hideYAxis,
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleChartClick = useCallback((e: any) => {
|
const getMenuItems = useCallback(
|
||||||
if (e?.activePayload?.[0]) {
|
(e: any, clickedData: any): ChartClickMenuItem[] => {
|
||||||
const clickedData = e.activePayload[0].payload;
|
const items: ChartClickMenuItem[] = [];
|
||||||
if (clickedData.date) {
|
|
||||||
pushModal('AddReference', {
|
if (!clickedData?.date) {
|
||||||
datetime: new Date(clickedData.date).toISOString(),
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract serie ID from the click event if needed
|
||||||
|
// activePayload is an array of payload objects
|
||||||
|
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', '');
|
||||||
|
|
||||||
|
// View Users - only show if we have projectId
|
||||||
|
if (projectId) {
|
||||||
|
items.push({
|
||||||
|
label: 'View Users',
|
||||||
|
icon: <UsersIcon size={16} />,
|
||||||
|
onClick: () => {
|
||||||
|
pushModal('ViewChartUsers', {
|
||||||
|
type: 'chart',
|
||||||
|
chartData: data,
|
||||||
|
report: {
|
||||||
|
projectId,
|
||||||
|
series: reportSeries,
|
||||||
|
breakdowns: breakdowns || [],
|
||||||
|
interval,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
range,
|
||||||
|
previous,
|
||||||
|
chartType: 'linear',
|
||||||
|
metric: 'sum',
|
||||||
|
},
|
||||||
|
date: clickedData.date,
|
||||||
|
});
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}, []);
|
// Add Reference - always show
|
||||||
|
items.push({
|
||||||
|
label: 'Add Reference',
|
||||||
|
icon: <BookmarkIcon size={16} />,
|
||||||
|
onClick: () => {
|
||||||
|
pushModal('AddReference', {
|
||||||
|
datetime: new Date(clickedData.date).toISOString(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return items;
|
||||||
|
},
|
||||||
|
[
|
||||||
|
projectId,
|
||||||
|
data,
|
||||||
|
reportSeries,
|
||||||
|
breakdowns,
|
||||||
|
interval,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
range,
|
||||||
|
previous,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ReportChartTooltip.TooltipProvider references={references.data}>
|
<ReportChartTooltip.TooltipProvider references={references.data}>
|
||||||
<div className={cn('h-full w-full', isEditMode && 'card p-4')}>
|
<ChartClickMenu getMenuItems={getMenuItems}>
|
||||||
<ResponsiveContainer>
|
<div className={cn('h-full w-full', isEditMode && 'card p-4')}>
|
||||||
<ComposedChart data={rechartData} onClick={handleChartClick}>
|
<ResponsiveContainer>
|
||||||
<Customized component={calcStrokeDasharray} />
|
<ComposedChart data={rechartData}>
|
||||||
<Line
|
<Customized component={calcStrokeDasharray} />
|
||||||
dataKey="calcStrokeDasharray"
|
<Line
|
||||||
legendType="none"
|
dataKey="calcStrokeDasharray"
|
||||||
animationDuration={0}
|
legendType="none"
|
||||||
onAnimationEnd={handleAnimationEnd}
|
animationDuration={0}
|
||||||
/>
|
onAnimationEnd={handleAnimationEnd}
|
||||||
<CartesianGrid
|
|
||||||
strokeDasharray="3 3"
|
|
||||||
horizontal={true}
|
|
||||||
vertical={false}
|
|
||||||
className="stroke-border"
|
|
||||||
/>
|
|
||||||
{references.data?.map((ref) => (
|
|
||||||
<ReferenceLine
|
|
||||||
key={ref.id}
|
|
||||||
x={ref.date.getTime()}
|
|
||||||
stroke={'oklch(from var(--foreground) l c h / 0.1)'}
|
|
||||||
strokeDasharray={'3 3'}
|
|
||||||
label={{
|
|
||||||
value: ref.title,
|
|
||||||
position: 'centerTop',
|
|
||||||
fill: '#334155',
|
|
||||||
fontSize: 12,
|
|
||||||
}}
|
|
||||||
fontSize={10}
|
|
||||||
/>
|
/>
|
||||||
))}
|
<CartesianGrid
|
||||||
<YAxis
|
strokeDasharray="3 3"
|
||||||
{...yAxisProps}
|
horizontal={true}
|
||||||
domain={maxDomain ? [0, maxDomain] : undefined}
|
vertical={false}
|
||||||
/>
|
className="stroke-border"
|
||||||
<XAxis {...xAxisProps} />
|
/>
|
||||||
{series.length > 1 && <Legend content={<CustomLegend />} />}
|
{references.data?.map((ref) => (
|
||||||
<Tooltip content={<ReportChartTooltip.Tooltip />} />
|
<ReferenceLine
|
||||||
{/* {series.map((serie) => {
|
key={ref.id}
|
||||||
const color = getChartColor(serie.index);
|
x={ref.date.getTime()}
|
||||||
return (
|
stroke={'oklch(from var(--foreground) l c h / 0.1)'}
|
||||||
<React.Fragment key={serie.id}>
|
strokeDasharray={'3 3'}
|
||||||
<defs>
|
label={{
|
||||||
{isAreaStyle && (
|
value: ref.title,
|
||||||
<linearGradient
|
position: 'centerTop',
|
||||||
id={`color${color}`}
|
fill: '#334155',
|
||||||
x1="0"
|
fontSize: 12,
|
||||||
y1="0"
|
}}
|
||||||
x2="0"
|
fontSize={10}
|
||||||
y2="1"
|
/>
|
||||||
>
|
))}
|
||||||
<stop offset="0%" stopColor={color} stopOpacity={0.8} />
|
<YAxis
|
||||||
<stop
|
{...yAxisProps}
|
||||||
offset="100%"
|
domain={maxDomain ? [0, maxDomain] : undefined}
|
||||||
stopColor={color}
|
/>
|
||||||
stopOpacity={0.1}
|
<XAxis {...xAxisProps} />
|
||||||
/>
|
{series.length > 1 && <Legend content={<CustomLegend />} />}
|
||||||
</linearGradient>
|
<Tooltip content={<ReportChartTooltip.Tooltip />} />
|
||||||
)}
|
|
||||||
</defs>
|
<defs>
|
||||||
|
<filter
|
||||||
|
id="rainbow-line-glow"
|
||||||
|
x="-20%"
|
||||||
|
y="-20%"
|
||||||
|
width="140%"
|
||||||
|
height="140%"
|
||||||
|
>
|
||||||
|
<feGaussianBlur stdDeviation="5" result="blur" />
|
||||||
|
<feComponentTransfer in="blur" result="dimmedBlur">
|
||||||
|
<feFuncA type="linear" slope="0.5" />
|
||||||
|
</feComponentTransfer>
|
||||||
|
<feComposite
|
||||||
|
in="SourceGraphic"
|
||||||
|
in2="dimmedBlur"
|
||||||
|
operator="over"
|
||||||
|
/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
{series.map((serie) => {
|
||||||
|
const color = getChartColor(serie.index);
|
||||||
|
return (
|
||||||
<Line
|
<Line
|
||||||
dot={isAreaStyle && dataLength <= 8}
|
key={serie.id}
|
||||||
|
dot={dataLength <= 8}
|
||||||
type={lineType}
|
type={lineType}
|
||||||
name={serie.id}
|
name={serie.id}
|
||||||
isAnimationActive={false}
|
isAnimationActive={false}
|
||||||
@@ -216,100 +288,46 @@ export function Chart({ data }: Props) {
|
|||||||
}
|
}
|
||||||
// Use for legend
|
// Use for legend
|
||||||
fill={color}
|
fill={color}
|
||||||
|
filter={
|
||||||
|
series.length === 1
|
||||||
|
? 'url(#rainbow-line-glow)'
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
{previous && (
|
);
|
||||||
<Line
|
})}
|
||||||
type={lineType}
|
|
||||||
name={`${serie.id}:prev`}
|
|
||||||
isAnimationActive
|
|
||||||
dot={false}
|
|
||||||
strokeOpacity={0.3}
|
|
||||||
dataKey={`${serie.id}:prev:count`}
|
|
||||||
stroke={color}
|
|
||||||
// Use for legend
|
|
||||||
fill={color}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</React.Fragment>
|
|
||||||
);
|
|
||||||
})} */}
|
|
||||||
|
|
||||||
<defs>
|
{/* Previous */}
|
||||||
<filter
|
{previous
|
||||||
id="rainbow-line-glow"
|
? series.map((serie) => {
|
||||||
x="-20%"
|
const color = getChartColor(serie.index);
|
||||||
y="-20%"
|
return (
|
||||||
width="140%"
|
<Line
|
||||||
height="140%"
|
key={`${serie.id}:prev`}
|
||||||
>
|
type={lineType}
|
||||||
<feGaussianBlur stdDeviation="5" result="blur" />
|
name={`${serie.id}:prev`}
|
||||||
<feComponentTransfer in="blur" result="dimmedBlur">
|
isAnimationActive
|
||||||
<feFuncA type="linear" slope="0.5" />
|
dot={false}
|
||||||
</feComponentTransfer>
|
strokeOpacity={0.3}
|
||||||
<feComposite
|
dataKey={`${serie.id}:prev:count`}
|
||||||
in="SourceGraphic"
|
stroke={color}
|
||||||
in2="dimmedBlur"
|
// Use for legend
|
||||||
operator="over"
|
fill={color}
|
||||||
/>
|
/>
|
||||||
</filter>
|
);
|
||||||
</defs>
|
})
|
||||||
|
: null}
|
||||||
{series.map((serie) => {
|
</ComposedChart>
|
||||||
const color = getChartColor(serie.index);
|
</ResponsiveContainer>
|
||||||
return (
|
</div>
|
||||||
<Line
|
{isEditMode && (
|
||||||
key={serie.id}
|
<ReportTable
|
||||||
dot={dataLength <= 8}
|
data={data}
|
||||||
type={lineType}
|
visibleSeries={series}
|
||||||
name={serie.id}
|
setVisibleSeries={setVisibleSeries}
|
||||||
isAnimationActive={false}
|
/>
|
||||||
strokeWidth={2}
|
)}
|
||||||
dataKey={`${serie.id}:count`}
|
</ChartClickMenu>
|
||||||
stroke={color}
|
|
||||||
strokeDasharray={
|
|
||||||
useDashedLastLine
|
|
||||||
? getStrokeDasharray(`${serie.id}:count`)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
// Use for legend
|
|
||||||
fill={color}
|
|
||||||
filter={
|
|
||||||
series.length === 1 ? 'url(#rainbow-line-glow)' : undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* Previous */}
|
|
||||||
{previous
|
|
||||||
? series.map((serie) => {
|
|
||||||
const color = getChartColor(serie.index);
|
|
||||||
return (
|
|
||||||
<Line
|
|
||||||
key={`${serie.id}:prev`}
|
|
||||||
type={lineType}
|
|
||||||
name={`${serie.id}:prev`}
|
|
||||||
isAnimationActive
|
|
||||||
dot={false}
|
|
||||||
strokeOpacity={0.3}
|
|
||||||
dataKey={`${serie.id}:prev:count`}
|
|
||||||
stroke={color}
|
|
||||||
// Use for legend
|
|
||||||
fill={color}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
: null}
|
|
||||||
</ComposedChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
{isEditMode && (
|
|
||||||
<ReportTable
|
|
||||||
data={data}
|
|
||||||
visibleSeries={series}
|
|
||||||
setVisibleSeries={setVisibleSeries}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</ReportChartTooltip.TooltipProvider>
|
</ReportChartTooltip.TooltipProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,7 +89,10 @@ export function MetricCard({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn('group relative p-4', isEditMode && 'card h-auto')}
|
className={cn(
|
||||||
|
'group relative p-4 hover:z-10',
|
||||||
|
isEditMode && 'card h-auto',
|
||||||
|
)}
|
||||||
key={serie.id}
|
key={serie.id}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import CohortTable from './table';
|
|||||||
export function ReportRetentionChart() {
|
export function ReportRetentionChart() {
|
||||||
const {
|
const {
|
||||||
report: {
|
report: {
|
||||||
events,
|
series,
|
||||||
range,
|
range,
|
||||||
projectId,
|
projectId,
|
||||||
startDate,
|
startDate,
|
||||||
@@ -22,8 +22,9 @@ export function ReportRetentionChart() {
|
|||||||
},
|
},
|
||||||
isLazyLoading,
|
isLazyLoading,
|
||||||
} = useReportChartContext();
|
} = useReportChartContext();
|
||||||
const firstEvent = (events[0]?.filters[0]?.value ?? []).map(String);
|
const eventSeries = series.filter((item) => item.type === 'event');
|
||||||
const secondEvent = (events[1]?.filters[0]?.value ?? []).map(String);
|
const firstEvent = (eventSeries[0]?.filters?.[0]?.value ?? []).map(String);
|
||||||
|
const secondEvent = (eventSeries[1]?.filters?.[0]?.value ?? []).map(String);
|
||||||
const isEnabled =
|
const isEnabled =
|
||||||
firstEvent.length > 0 && secondEvent.length > 0 && !isLazyLoading;
|
firstEvent.length > 0 && secondEvent.length > 0 && !isLazyLoading;
|
||||||
const trpc = useTRPC();
|
const trpc = useTRPC();
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ type ChartRootShortcutProps = Omit<ReportChartProps, 'report'> & {
|
|||||||
previous?: ReportChartProps['report']['previous'];
|
previous?: ReportChartProps['report']['previous'];
|
||||||
chartType?: ReportChartProps['report']['chartType'];
|
chartType?: ReportChartProps['report']['chartType'];
|
||||||
interval?: ReportChartProps['report']['interval'];
|
interval?: ReportChartProps['report']['interval'];
|
||||||
events: ReportChartProps['report']['events'];
|
series: ReportChartProps['report']['series'];
|
||||||
breakdowns?: ReportChartProps['report']['breakdowns'];
|
breakdowns?: ReportChartProps['report']['breakdowns'];
|
||||||
lineType?: ReportChartProps['report']['lineType'];
|
lineType?: ReportChartProps['report']['lineType'];
|
||||||
};
|
};
|
||||||
@@ -18,7 +18,7 @@ export const ReportChartShortcut = ({
|
|||||||
previous = false,
|
previous = false,
|
||||||
chartType = 'linear',
|
chartType = 'linear',
|
||||||
interval = 'day',
|
interval = 'day',
|
||||||
events,
|
series,
|
||||||
breakdowns,
|
breakdowns,
|
||||||
lineType = 'monotone',
|
lineType = 'monotone',
|
||||||
options,
|
options,
|
||||||
@@ -33,7 +33,7 @@ export const ReportChartShortcut = ({
|
|||||||
previous,
|
previous,
|
||||||
chartType,
|
chartType,
|
||||||
interval,
|
interval,
|
||||||
events,
|
series,
|
||||||
lineType,
|
lineType,
|
||||||
metric: 'sum',
|
metric: 'sum',
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -11,12 +11,14 @@ import {
|
|||||||
} from '@openpanel/constants';
|
} from '@openpanel/constants';
|
||||||
import type {
|
import type {
|
||||||
IChartBreakdown,
|
IChartBreakdown,
|
||||||
IChartEvent,
|
IChartEventItem,
|
||||||
|
IChartFormula,
|
||||||
IChartLineType,
|
IChartLineType,
|
||||||
IChartProps,
|
IChartProps,
|
||||||
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';
|
||||||
@@ -39,7 +41,7 @@ const initialState: InitialState = {
|
|||||||
lineType: 'monotone',
|
lineType: 'monotone',
|
||||||
interval: 'day',
|
interval: 'day',
|
||||||
breakdowns: [],
|
breakdowns: [],
|
||||||
events: [],
|
series: [],
|
||||||
range: '30d',
|
range: '30d',
|
||||||
startDate: null,
|
startDate: null,
|
||||||
endDate: null,
|
endDate: null,
|
||||||
@@ -86,24 +88,34 @@ export const reportSlice = createSlice({
|
|||||||
state.dirty = true;
|
state.dirty = true;
|
||||||
state.name = action.payload;
|
state.name = action.payload;
|
||||||
},
|
},
|
||||||
// Events
|
// Series (Events and Formulas)
|
||||||
addEvent: (state, action: PayloadAction<Omit<IChartEvent, 'id'>>) => {
|
addSerie: (
|
||||||
|
state,
|
||||||
|
action: PayloadAction<UnionOmit<IChartEventItem, 'id'>>,
|
||||||
|
) => {
|
||||||
state.dirty = true;
|
state.dirty = true;
|
||||||
state.events.push({
|
state.series.push({
|
||||||
id: shortId(),
|
id: shortId(),
|
||||||
...action.payload,
|
...action.payload,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
duplicateEvent: (state, action: PayloadAction<Omit<IChartEvent, 'id'>>) => {
|
duplicateEvent: (state, action: PayloadAction<IChartEventItem>) => {
|
||||||
state.dirty = true;
|
state.dirty = true;
|
||||||
state.events.push({
|
if (action.payload.type === 'event') {
|
||||||
...action.payload,
|
state.series.push({
|
||||||
filters: action.payload.filters.map((filter) => ({
|
...action.payload,
|
||||||
...filter,
|
filters: action.payload.filters.map((filter) => ({
|
||||||
|
...filter,
|
||||||
|
id: shortId(),
|
||||||
|
})),
|
||||||
id: shortId(),
|
id: shortId(),
|
||||||
})),
|
} as IChartEventItem);
|
||||||
id: shortId(),
|
} else {
|
||||||
});
|
state.series.push({
|
||||||
|
...action.payload,
|
||||||
|
id: shortId(),
|
||||||
|
} as IChartEventItem);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
removeEvent: (
|
removeEvent: (
|
||||||
state,
|
state,
|
||||||
@@ -112,13 +124,13 @@ export const reportSlice = createSlice({
|
|||||||
}>,
|
}>,
|
||||||
) => {
|
) => {
|
||||||
state.dirty = true;
|
state.dirty = true;
|
||||||
state.events = state.events.filter(
|
state.series = state.series.filter((event) => {
|
||||||
(event) => event.id !== action.payload.id,
|
return event.id !== action.payload.id;
|
||||||
);
|
});
|
||||||
},
|
},
|
||||||
changeEvent: (state, action: PayloadAction<IChartEvent>) => {
|
changeEvent: (state, action: PayloadAction<IChartEventItem>) => {
|
||||||
state.dirty = true;
|
state.dirty = true;
|
||||||
state.events = state.events.map((event) => {
|
state.series = state.series.map((event) => {
|
||||||
if (event.id === action.payload.id) {
|
if (event.id === action.payload.id) {
|
||||||
return action.payload;
|
return action.payload;
|
||||||
}
|
}
|
||||||
@@ -265,9 +277,9 @@ export const reportSlice = createSlice({
|
|||||||
) {
|
) {
|
||||||
state.dirty = true;
|
state.dirty = true;
|
||||||
const { fromIndex, toIndex } = action.payload;
|
const { fromIndex, toIndex } = action.payload;
|
||||||
const [movedEvent] = state.events.splice(fromIndex, 1);
|
const [movedEvent] = state.series.splice(fromIndex, 1);
|
||||||
if (movedEvent) {
|
if (movedEvent) {
|
||||||
state.events.splice(toIndex, 0, movedEvent);
|
state.series.splice(toIndex, 0, movedEvent);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -279,7 +291,7 @@ export const {
|
|||||||
ready,
|
ready,
|
||||||
setReport,
|
setReport,
|
||||||
setName,
|
setName,
|
||||||
addEvent,
|
addSerie,
|
||||||
removeEvent,
|
removeEvent,
|
||||||
duplicateEvent,
|
duplicateEvent,
|
||||||
changeEvent,
|
changeEvent,
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { Combobox } from '@/components/ui/combobox';
|
import { Combobox } from '@/components/ui/combobox';
|
||||||
import { useAppParams } from '@/hooks/use-app-params';
|
import { useAppParams } from '@/hooks/use-app-params';
|
||||||
import { useEventProperties } from '@/hooks/use-event-properties';
|
import { useEventProperties } from '@/hooks/use-event-properties';
|
||||||
import { useDispatch, useSelector } from '@/redux';
|
import { useDispatch } from '@/redux';
|
||||||
import { api } from '@/trpc/client';
|
|
||||||
import { cn } from '@/utils/cn';
|
import { cn } from '@/utils/cn';
|
||||||
import { DatabaseIcon } from 'lucide-react';
|
import { DatabaseIcon } from 'lucide-react';
|
||||||
|
|
||||||
@@ -43,6 +42,7 @@ export function EventPropertiesCombobox({
|
|||||||
changeEvent({
|
changeEvent({
|
||||||
...event,
|
...event,
|
||||||
property: value,
|
property: value,
|
||||||
|
type: 'event',
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { ColorSquare } from '@/components/color-square';
|
import { ColorSquare } from '@/components/color-square';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
import { ComboboxEvents } from '@/components/ui/combobox-events';
|
import { ComboboxEvents } from '@/components/ui/combobox-events';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { InputEnter } from '@/components/ui/input-enter';
|
||||||
import { useAppParams } from '@/hooks/use-app-params';
|
import { useAppParams } from '@/hooks/use-app-params';
|
||||||
import { useDebounceFn } from '@/hooks/use-debounce-fn';
|
import { useDebounceFn } from '@/hooks/use-debounce-fn';
|
||||||
import { useEventNames } from '@/hooks/use-event-names';
|
import { useEventNames } from '@/hooks/use-event-names';
|
||||||
@@ -23,11 +25,11 @@ import {
|
|||||||
import { CSS } from '@dnd-kit/utilities';
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
import { shortId } from '@openpanel/common';
|
import { shortId } from '@openpanel/common';
|
||||||
import { alphabetIds } from '@openpanel/constants';
|
import { alphabetIds } from '@openpanel/constants';
|
||||||
import type { IChartEvent } from '@openpanel/validation';
|
import type { IChartEventItem, IChartFormula } from '@openpanel/validation';
|
||||||
import { FilterIcon, HandIcon } from 'lucide-react';
|
import { FilterIcon, HandIcon, PlusIcon } from 'lucide-react';
|
||||||
import { ReportSegment } from '../ReportSegment';
|
import { ReportSegment } from '../ReportSegment';
|
||||||
import {
|
import {
|
||||||
addEvent,
|
addSerie,
|
||||||
changeEvent,
|
changeEvent,
|
||||||
duplicateEvent,
|
duplicateEvent,
|
||||||
removeEvent,
|
removeEvent,
|
||||||
@@ -47,7 +49,7 @@ function SortableEvent({
|
|||||||
isSelectManyEvents,
|
isSelectManyEvents,
|
||||||
...props
|
...props
|
||||||
}: {
|
}: {
|
||||||
event: IChartEvent;
|
event: IChartEventItem;
|
||||||
index: number;
|
index: number;
|
||||||
showSegment: boolean;
|
showSegment: boolean;
|
||||||
showAddFilter: boolean;
|
showAddFilter: boolean;
|
||||||
@@ -62,6 +64,8 @@ function SortableEvent({
|
|||||||
transition,
|
transition,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isEvent = event.type === 'event';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={setNodeRef} style={style} {...attributes} {...props}>
|
<div ref={setNodeRef} style={style} {...attributes} {...props}>
|
||||||
<div className="flex items-center gap-2 p-2 group">
|
<div className="flex items-center gap-2 p-2 group">
|
||||||
@@ -76,8 +80,8 @@ function SortableEvent({
|
|||||||
{props.children}
|
{props.children}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Segment and Filter buttons */}
|
{/* Segment and Filter buttons - only for events */}
|
||||||
{(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
|
||||||
@@ -130,14 +134,14 @@ function SortableEvent({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters - only for events */}
|
||||||
{!isSelectManyEvents && <FiltersList event={event} />}
|
{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();
|
||||||
@@ -151,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';
|
||||||
@@ -174,11 +178,15 @@ export function ReportEvents() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMore = (event: 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(event));
|
return dispatch(
|
||||||
|
removeEvent({
|
||||||
|
id: event.id,
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
case 'duplicate': {
|
case 'duplicate': {
|
||||||
return dispatch(duplicateEvent(event));
|
return dispatch(duplicateEvent(event));
|
||||||
@@ -189,20 +197,31 @@ export function ReportEvents() {
|
|||||||
return callback;
|
return callback;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const dispatchChangeFormula = useDebounceFn((formula: IChartFormula) => {
|
||||||
|
dispatch(changeEvent(formula));
|
||||||
|
});
|
||||||
|
|
||||||
|
const showFormula =
|
||||||
|
chartType !== 'conversion' &&
|
||||||
|
chartType !== 'funnel' &&
|
||||||
|
chartType !== 'retention';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="mb-2 font-medium">Events</h3>
|
<h3 className="mb-2 font-medium">Metrics</h3>
|
||||||
<DndContext
|
<DndContext
|
||||||
sensors={sensors}
|
sensors={sensors}
|
||||||
collisionDetection={closestCenter}
|
collisionDetection={closestCenter}
|
||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
>
|
>
|
||||||
<SortableContext
|
<SortableContext
|
||||||
items={selectedEvents.map((e) => ({ id: e.id ?? '' }))}
|
items={selectedEvents.map((e) => ({ id: 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) => {
|
||||||
|
const isFormula = event.type === 'formula';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SortableEvent
|
<SortableEvent
|
||||||
key={event.id}
|
key={event.id}
|
||||||
@@ -213,95 +232,151 @@ export function ReportEvents() {
|
|||||||
isSelectManyEvents={isSelectManyEvents}
|
isSelectManyEvents={isSelectManyEvents}
|
||||||
className="rounded-lg border bg-def-100"
|
className="rounded-lg border bg-def-100"
|
||||||
>
|
>
|
||||||
<ComboboxEvents
|
{isFormula ? (
|
||||||
className="flex-1"
|
<>
|
||||||
searchable
|
<div className="flex-1 flex flex-col gap-2">
|
||||||
multiple={isSelectManyEvents as false}
|
<InputEnter
|
||||||
value={
|
placeholder="eg: A+B, A/B"
|
||||||
(isSelectManyEvents
|
value={event.formula}
|
||||||
? (event.filters[0]?.value ?? [])
|
onChangeValue={(value) => {
|
||||||
: event.name) as any
|
dispatchChangeFormula({
|
||||||
}
|
...event,
|
||||||
onChange={(value) => {
|
formula: value,
|
||||||
dispatch(
|
});
|
||||||
changeEvent(
|
}}
|
||||||
Array.isArray(value)
|
/>
|
||||||
? {
|
{showDisplayNameInput && (
|
||||||
id: event.id,
|
<Input
|
||||||
segment: 'user',
|
placeholder={`Formula (${alphabetIds[index]})`}
|
||||||
filters: [
|
defaultValue={event.displayName}
|
||||||
{
|
onChange={(e) => {
|
||||||
name: 'name',
|
dispatchChangeFormula({
|
||||||
operator: 'is',
|
|
||||||
value: value,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
name: '*',
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
...event,
|
...event,
|
||||||
name: value,
|
displayName: e.target.value,
|
||||||
filters: [],
|
});
|
||||||
},
|
}}
|
||||||
),
|
/>
|
||||||
);
|
)}
|
||||||
}}
|
</div>
|
||||||
items={eventNames}
|
<ReportEventMore onClick={handleMore(event)} />
|
||||||
placeholder="Select event"
|
</>
|
||||||
/>
|
) : (
|
||||||
{showDisplayNameInput && (
|
<>
|
||||||
<Input
|
<ComboboxEvents
|
||||||
placeholder={
|
className="flex-1"
|
||||||
event.name
|
searchable
|
||||||
? `${event.name} (${alphabetIds[index]})`
|
multiple={isSelectManyEvents as false}
|
||||||
: 'Display name'
|
value={
|
||||||
}
|
isSelectManyEvents
|
||||||
defaultValue={event.displayName}
|
? (event.filters[0]?.value ?? [])
|
||||||
onChange={(e) => {
|
: (event.name as any)
|
||||||
dispatchChangeEvent({
|
}
|
||||||
...event,
|
onChange={(value) => {
|
||||||
displayName: e.target.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.name
|
||||||
|
? `${event.name} (${alphabetIds[index]})`
|
||||||
|
: 'Display name'
|
||||||
|
}
|
||||||
|
defaultValue={event.displayName}
|
||||||
|
onChange={(e) => {
|
||||||
|
dispatchChangeEvent({
|
||||||
|
...event,
|
||||||
|
displayName: e.target.value,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<ReportEventMore onClick={handleMore(event)} />
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<ReportEventMore onClick={handleMore(event)} />
|
|
||||||
</SortableEvent>
|
</SortableEvent>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
<ComboboxEvents
|
<div className="flex gap-2">
|
||||||
disabled={isAddEventDisabled}
|
<ComboboxEvents
|
||||||
value={''}
|
disabled={isAddEventDisabled}
|
||||||
searchable
|
value={''}
|
||||||
onChange={(value) => {
|
searchable
|
||||||
if (isSelectManyEvents) {
|
onChange={(value) => {
|
||||||
dispatch(
|
if (isSelectManyEvents) {
|
||||||
addEvent({
|
dispatch(
|
||||||
segment: 'user',
|
addSerie({
|
||||||
name: value,
|
type: 'event',
|
||||||
filters: [
|
segment: 'user',
|
||||||
{
|
name: value,
|
||||||
name: 'name',
|
filters: [
|
||||||
operator: 'is',
|
{
|
||||||
value: [value],
|
name: 'name',
|
||||||
},
|
operator: 'is',
|
||||||
],
|
value: [value],
|
||||||
}),
|
},
|
||||||
);
|
],
|
||||||
} else {
|
}),
|
||||||
dispatch(
|
);
|
||||||
addEvent({
|
} else {
|
||||||
name: value,
|
dispatch(
|
||||||
segment: 'event',
|
addSerie({
|
||||||
filters: [],
|
type: 'event',
|
||||||
}),
|
name: value,
|
||||||
);
|
segment: 'event',
|
||||||
}
|
filters: [],
|
||||||
}}
|
}),
|
||||||
placeholder="Select event"
|
);
|
||||||
items={eventNames}
|
}
|
||||||
/>
|
}}
|
||||||
|
placeholder="Select event"
|
||||||
|
items={eventNames}
|
||||||
|
/>
|
||||||
|
{showFormula && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
icon={PlusIcon}
|
||||||
|
onClick={() => {
|
||||||
|
dispatch(
|
||||||
|
addSerie({
|
||||||
|
type: 'formula',
|
||||||
|
formula: '',
|
||||||
|
displayName: '',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add Formula
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SortableContext>
|
</SortableContext>
|
||||||
</DndContext>
|
</DndContext>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
414
apps/start/src/components/report/sidebar/ReportSeries.tsx
Normal file
414
apps/start/src/components/report/sidebar/ReportSeries.tsx
Normal file
@@ -0,0 +1,414 @@
|
|||||||
|
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 } from 'lucide-react';
|
||||||
|
import { ReportSegment } from '../ReportSegment';
|
||||||
|
import {
|
||||||
|
addSerie,
|
||||||
|
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: IChartEventItem) => {
|
||||||
|
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(
|
||||||
|
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"
|
||||||
|
variant="outline"
|
||||||
|
icon={PiIcon}
|
||||||
|
onClick={() => {
|
||||||
|
dispatch(
|
||||||
|
addSerie({
|
||||||
|
type: 'formula',
|
||||||
|
formula: '',
|
||||||
|
displayName: '',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add Formula
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,23 +3,17 @@ import { SheetClose, SheetFooter } from '@/components/ui/sheet';
|
|||||||
import { useSelector } from '@/redux';
|
import { useSelector } from '@/redux';
|
||||||
|
|
||||||
import { ReportBreakdowns } from './ReportBreakdowns';
|
import { ReportBreakdowns } from './ReportBreakdowns';
|
||||||
import { ReportEvents } from './ReportEvents';
|
import { ReportSeries } from './ReportSeries';
|
||||||
import { ReportFormula } from './ReportFormula';
|
|
||||||
import { ReportSettings } from './ReportSettings';
|
import { ReportSettings } from './ReportSettings';
|
||||||
|
|
||||||
export function ReportSidebar() {
|
export function ReportSidebar() {
|
||||||
const { chartType } = useSelector((state) => state.report);
|
const { chartType } = useSelector((state) => state.report);
|
||||||
const showFormula =
|
|
||||||
chartType !== 'conversion' &&
|
|
||||||
chartType !== 'funnel' &&
|
|
||||||
chartType !== 'retention';
|
|
||||||
const showBreakdown = chartType !== 'retention';
|
const showBreakdown = chartType !== 'retention';
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col gap-8">
|
<div className="flex flex-col gap-8">
|
||||||
<ReportEvents />
|
<ReportSeries />
|
||||||
{showBreakdown && <ReportBreakdowns />}
|
{showBreakdown && <ReportBreakdowns />}
|
||||||
{showFormula && <ReportFormula />}
|
|
||||||
<ReportSettings />
|
<ReportSettings />
|
||||||
</div>
|
</div>
|
||||||
<SheetFooter>
|
<SheetFooter>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { AnimatePresence } from 'framer-motion';
|
|||||||
import { RefreshCcwIcon } from 'lucide-react';
|
import { RefreshCcwIcon } from 'lucide-react';
|
||||||
import { type InputHTMLAttributes, useEffect, useState } from 'react';
|
import { type InputHTMLAttributes, useEffect, useState } from 'react';
|
||||||
import { Badge } from './badge';
|
import { Badge } from './badge';
|
||||||
import { Input } from './input';
|
import { Input, type InputProps } from './input';
|
||||||
|
|
||||||
export function InputEnter({
|
export function InputEnter({
|
||||||
value,
|
value,
|
||||||
@@ -13,7 +13,7 @@ export function InputEnter({
|
|||||||
}: {
|
}: {
|
||||||
value: string | undefined;
|
value: string | undefined;
|
||||||
onChangeValue: (value: string) => void;
|
onChangeValue: (value: string) => void;
|
||||||
} & InputHTMLAttributes<HTMLInputElement>) {
|
} & InputProps) {
|
||||||
const [internalValue, setInternalValue] = useState(value ?? '');
|
const [internalValue, setInternalValue] = useState(value ?? '');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -33,7 +33,6 @@ export function InputEnter({
|
|||||||
onChangeValue(internalValue);
|
onChangeValue(internalValue);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
size="default"
|
|
||||||
/>
|
/>
|
||||||
<div className="absolute right-2 top-1/2 -translate-y-1/2">
|
<div className="absolute right-2 top-1/2 -translate-y-1/2">
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
|
|||||||
@@ -1,8 +1,36 @@
|
|||||||
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
|
import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area';
|
||||||
import type * as React from 'react';
|
import * as React from 'react';
|
||||||
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export const VirtualScrollArea = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
{
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
>(({ children, className }, ref) => {
|
||||||
|
// The ref MUST point directly to the scrollable element
|
||||||
|
// This element MUST have:
|
||||||
|
// 1. overflow-y-auto (or overflow: auto)
|
||||||
|
// 2. A constrained height (via flex-1 min-h-0 or fixed height)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn('overflow-y-auto w-full', className)}
|
||||||
|
style={{
|
||||||
|
// Ensure height is constrained by flex parent
|
||||||
|
height: '100%',
|
||||||
|
maxHeight: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
VirtualScrollArea.displayName = 'VirtualScrollArea';
|
||||||
|
|
||||||
function ScrollArea({
|
function ScrollArea({
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
|
|||||||
44
apps/start/src/hooks/use-conversion-rechart-data-model.ts
Normal file
44
apps/start/src/hooks/use-conversion-rechart-data-model.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import type { RouterOutputs } from '@/trpc/client';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
export function useConversionRechartDataModel(
|
||||||
|
series: RouterOutputs['chart']['conversion']['current'],
|
||||||
|
) {
|
||||||
|
return useMemo(() => {
|
||||||
|
if (!series.length || !series[0]?.data.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all unique dates from the first series (all series should have same dates)
|
||||||
|
const dates = series[0].data.map((item) => item.date);
|
||||||
|
|
||||||
|
return dates.map((date) => {
|
||||||
|
const baseItem = series[0].data.find((item) => item.date === date);
|
||||||
|
if (!baseItem) {
|
||||||
|
return {
|
||||||
|
date,
|
||||||
|
timestamp: new Date(date).getTime(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build data object with all series values
|
||||||
|
const dataPoint: Record<string, any> = {
|
||||||
|
date,
|
||||||
|
timestamp: new Date(date).getTime(),
|
||||||
|
};
|
||||||
|
|
||||||
|
series.forEach((serie) => {
|
||||||
|
const item = serie.data.find((d) => d.date === date);
|
||||||
|
if (item) {
|
||||||
|
dataPoint[`${serie.id}:rate`] = item.rate;
|
||||||
|
dataPoint[`${serie.id}:previousRate`] = item.previousRate;
|
||||||
|
dataPoint[`${serie.id}:total`] = item.total;
|
||||||
|
dataPoint[`${serie.id}:conversions`] = item.conversions;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return dataPoint;
|
||||||
|
});
|
||||||
|
}, [series]);
|
||||||
|
}
|
||||||
|
|
||||||
35
apps/start/src/hooks/use-visible-conversion-series.ts
Normal file
35
apps/start/src/hooks/use-visible-conversion-series.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import type { RouterOutputs } from '@/trpc/client';
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
export type IVisibleConversionSeries = ReturnType<
|
||||||
|
typeof useVisibleConversionSeries
|
||||||
|
>['series'];
|
||||||
|
|
||||||
|
export function useVisibleConversionSeries(
|
||||||
|
data: RouterOutputs['chart']['conversion'],
|
||||||
|
limit?: number | undefined,
|
||||||
|
) {
|
||||||
|
const max = limit ?? 5;
|
||||||
|
const [visibleSeries, setVisibleSeries] = useState<string[]>(
|
||||||
|
data?.current?.slice(0, max).map((serie) => serie.id) ?? [],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setVisibleSeries(
|
||||||
|
data?.current?.slice(0, max).map((serie) => serie.id) ?? [],
|
||||||
|
);
|
||||||
|
}, [data, max]);
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
return {
|
||||||
|
series: data.current
|
||||||
|
.map((serie, index) => ({
|
||||||
|
...serie,
|
||||||
|
index,
|
||||||
|
}))
|
||||||
|
.filter((serie) => visibleSeries.includes(serie.id)),
|
||||||
|
setVisibleSeries,
|
||||||
|
} as const;
|
||||||
|
}, [visibleSeries, data.current]);
|
||||||
|
}
|
||||||
|
|
||||||
44
apps/start/src/modals/Modal/scrollable-modal.tsx
Normal file
44
apps/start/src/modals/Modal/scrollable-modal.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { ScrollArea, VirtualScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
import { cn } from '@/utils/cn';
|
||||||
|
import { createContext, useContext, useRef } from 'react';
|
||||||
|
import { ModalContent } from './Container';
|
||||||
|
|
||||||
|
const ScrollableModalContext = createContext<{
|
||||||
|
scrollAreaRef: React.RefObject<HTMLDivElement | null>;
|
||||||
|
}>({
|
||||||
|
scrollAreaRef: { current: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
export function useScrollableModal() {
|
||||||
|
return useContext(ScrollableModalContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScrollableModal({
|
||||||
|
header,
|
||||||
|
footer,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
header: React.ReactNode;
|
||||||
|
footer?: React.ReactNode;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const scrollAreaRef = useRef<HTMLDivElement>(null);
|
||||||
|
return (
|
||||||
|
<ScrollableModalContext.Provider value={{ scrollAreaRef }}>
|
||||||
|
<ModalContent className="flex !max-h-[90vh] flex-col p-0 gap-0">
|
||||||
|
<div className="flex-shrink-0 p-6">{header}</div>
|
||||||
|
<VirtualScrollArea
|
||||||
|
ref={scrollAreaRef}
|
||||||
|
className={cn(
|
||||||
|
'flex-1 min-h-0 w-full',
|
||||||
|
footer && 'border-b',
|
||||||
|
header && 'border-t',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</VirtualScrollArea>
|
||||||
|
{footer && <div className="flex-shrink-0 p-6">{footer}</div>}
|
||||||
|
</ModalContent>
|
||||||
|
</ScrollableModalContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import RequestPasswordReset from './request-reset-password';
|
|||||||
import SaveReport from './save-report';
|
import SaveReport from './save-report';
|
||||||
import SelectBillingPlan from './select-billing-plan';
|
import SelectBillingPlan from './select-billing-plan';
|
||||||
import ShareOverviewModal from './share-overview-modal';
|
import ShareOverviewModal from './share-overview-modal';
|
||||||
|
import ViewChartUsers from './view-chart-users';
|
||||||
|
|
||||||
const modals = {
|
const modals = {
|
||||||
OverviewTopPagesModal: OverviewTopPagesModal,
|
OverviewTopPagesModal: OverviewTopPagesModal,
|
||||||
@@ -51,6 +52,7 @@ const modals = {
|
|||||||
EditReference: EditReference,
|
EditReference: EditReference,
|
||||||
ShareOverviewModal: ShareOverviewModal,
|
ShareOverviewModal: ShareOverviewModal,
|
||||||
AddReference: AddReference,
|
AddReference: AddReference,
|
||||||
|
ViewChartUsers: ViewChartUsers,
|
||||||
Instructions: Instructions,
|
Instructions: Instructions,
|
||||||
OnboardingTroubleshoot: OnboardingTroubleshoot,
|
OnboardingTroubleshoot: OnboardingTroubleshoot,
|
||||||
DateRangerPicker: DateRangerPicker,
|
DateRangerPicker: DateRangerPicker,
|
||||||
|
|||||||
398
apps/start/src/modals/view-chart-users.tsx
Normal file
398
apps/start/src/modals/view-chart-users.tsx
Normal file
@@ -0,0 +1,398 @@
|
|||||||
|
import { ProjectLink } from '@/components/links';
|
||||||
|
import { ProfileAvatar } from '@/components/profiles/profile-avatar';
|
||||||
|
import { SerieIcon } from '@/components/report-chart/common/serie-icon';
|
||||||
|
import { DropdownMenuShortcut } from '@/components/ui/dropdown-menu';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
|
import type { IChartData } from '@/trpc/client';
|
||||||
|
import { cn } from '@/utils/cn';
|
||||||
|
import { getProfileName } from '@/utils/getters';
|
||||||
|
import type { IChartInput } from '@openpanel/validation';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { popModal } from '.';
|
||||||
|
import { ModalHeader } from './Modal/Container';
|
||||||
|
import { ScrollableModal, useScrollableModal } from './Modal/scrollable-modal';
|
||||||
|
|
||||||
|
const ProfileItem = ({ profile }: { profile: any }) => {
|
||||||
|
return (
|
||||||
|
<ProjectLink
|
||||||
|
preload={false}
|
||||||
|
href={`/profiles/${profile.id}`}
|
||||||
|
title={getProfileName(profile, false)}
|
||||||
|
className="col gap-2 rounded-lg border p-2 bg-card"
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.metaKey || e.ctrlKey || e.shiftKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
popModal();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="row gap-2 items-center">
|
||||||
|
<ProfileAvatar {...profile} />
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="font-medium">{getProfileName(profile)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="row gap-4 text-sm overflow-hidden">
|
||||||
|
{profile.properties.country && (
|
||||||
|
<div className="row gap-2 items-center">
|
||||||
|
<SerieIcon name={profile.properties.country} />
|
||||||
|
<span>
|
||||||
|
{profile.properties.country}
|
||||||
|
{profile.properties.city && ` / ${profile.properties.city}`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{profile.properties.os && (
|
||||||
|
<div className="row gap-2 items-center">
|
||||||
|
<SerieIcon name={profile.properties.os} />
|
||||||
|
<span>{profile.properties.os}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{profile.properties.browser && (
|
||||||
|
<div className="row gap-2 items-center">
|
||||||
|
<SerieIcon name={profile.properties.browser} />
|
||||||
|
<span>{profile.properties.browser}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ProjectLink>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
// Shared profile list component
|
||||||
|
function ProfileList({ profiles }: { profiles: any[] }) {
|
||||||
|
const ITEM_HEIGHT = 74;
|
||||||
|
const CONTAINER_PADDING = 20;
|
||||||
|
const ITEM_GAP = 5;
|
||||||
|
const { scrollAreaRef } = useScrollableModal();
|
||||||
|
const [isScrollReady, setIsScrollReady] = useState(false);
|
||||||
|
|
||||||
|
// Check if scroll container is ready
|
||||||
|
useEffect(() => {
|
||||||
|
if (scrollAreaRef.current) {
|
||||||
|
setIsScrollReady(true);
|
||||||
|
} else {
|
||||||
|
setIsScrollReady(false);
|
||||||
|
}
|
||||||
|
}, [scrollAreaRef]);
|
||||||
|
|
||||||
|
const virtualizer = useVirtualizer({
|
||||||
|
count: profiles.length,
|
||||||
|
getScrollElement: () => scrollAreaRef.current,
|
||||||
|
estimateSize: () => ITEM_HEIGHT + ITEM_GAP,
|
||||||
|
overscan: 5,
|
||||||
|
paddingStart: CONTAINER_PADDING,
|
||||||
|
paddingEnd: CONTAINER_PADDING,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Re-measure when scroll container becomes available or profiles change
|
||||||
|
useEffect(() => {
|
||||||
|
if (isScrollReady && scrollAreaRef.current) {
|
||||||
|
// Small delay to ensure DOM is ready
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
virtualizer.measure();
|
||||||
|
}, 0);
|
||||||
|
return () => clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
}, [isScrollReady, profiles.length, virtualizer]);
|
||||||
|
|
||||||
|
if (profiles.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<div className="text-muted-foreground">No users found</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const virtualItems = virtualizer.getVirtualItems();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: `${virtualizer.getTotalSize()}px`,
|
||||||
|
width: '100%',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Only the visible items in the virtualizer, manually positioned to be in view */}
|
||||||
|
{virtualItems.map((virtualItem) => {
|
||||||
|
const profile = profiles[virtualItem.index];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={profile.id}
|
||||||
|
data-index={virtualItem.index}
|
||||||
|
ref={virtualizer.measureElement}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: '100%',
|
||||||
|
height: `${virtualItem.size}px`,
|
||||||
|
transform: `translateY(${virtualItem.start}px)`,
|
||||||
|
padding: `0px ${CONTAINER_PADDING}px ${ITEM_GAP}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ProfileItem profile={profile} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chart-specific props and component
|
||||||
|
interface ChartUsersViewProps {
|
||||||
|
chartData: IChartData;
|
||||||
|
report: IChartInput;
|
||||||
|
date: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChartUsersView({ chartData, report, date }: ChartUsersViewProps) {
|
||||||
|
const trpc = useTRPC();
|
||||||
|
const [selectedSerieId, setSelectedSerieId] = useState<string | null>(
|
||||||
|
report.series[0]?.id || null,
|
||||||
|
);
|
||||||
|
const [selectedBreakdownId, setSelectedBreakdownId] = useState<string | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedReportSerie = useMemo(
|
||||||
|
() => report.series.find((s) => s.id === selectedSerieId),
|
||||||
|
[report.series, selectedSerieId],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get all chart series that match the selected report serie
|
||||||
|
const matchingChartSeries = useMemo(() => {
|
||||||
|
if (!selectedSerieId || !chartData) return [];
|
||||||
|
return chartData.series.filter((s) => s.event.id === selectedSerieId);
|
||||||
|
}, [chartData?.series, selectedSerieId]);
|
||||||
|
|
||||||
|
const selectedBreakdown = useMemo(() => {
|
||||||
|
if (!selectedBreakdownId) return null;
|
||||||
|
return matchingChartSeries.find((s) => s.id === selectedBreakdownId);
|
||||||
|
}, [matchingChartSeries, selectedBreakdownId]);
|
||||||
|
|
||||||
|
// Reset breakdown selection when serie changes
|
||||||
|
const handleSerieChange = (value: string) => {
|
||||||
|
setSelectedSerieId(value);
|
||||||
|
setSelectedBreakdownId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const profilesQuery = useQuery(
|
||||||
|
trpc.chart.getProfiles.queryOptions(
|
||||||
|
{
|
||||||
|
projectId: report.projectId,
|
||||||
|
date: date,
|
||||||
|
series:
|
||||||
|
selectedReportSerie && selectedReportSerie.type === 'event'
|
||||||
|
? [selectedReportSerie]
|
||||||
|
: [],
|
||||||
|
breakdowns: selectedBreakdown?.event.breakdowns,
|
||||||
|
interval: report.interval,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: !!selectedReportSerie && selectedReportSerie.type === 'event',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const profiles = profilesQuery.data ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollableModal
|
||||||
|
header={
|
||||||
|
<div>
|
||||||
|
<ModalHeader
|
||||||
|
title="View Users"
|
||||||
|
text={`Users who performed actions on ${new Date(date).toLocaleDateString()}`}
|
||||||
|
/>
|
||||||
|
{report.series.length > 0 && (
|
||||||
|
<div className="col md:row gap-2">
|
||||||
|
<Select
|
||||||
|
value={selectedSerieId || ''}
|
||||||
|
onValueChange={handleSerieChange}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="flex-1">
|
||||||
|
<SelectValue placeholder="Select Serie" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{report.series.map((serie) => (
|
||||||
|
<SelectItem key={serie.id} value={serie.id || ''}>
|
||||||
|
{serie.type === 'event'
|
||||||
|
? serie.displayName || serie.name
|
||||||
|
: serie.displayName || 'Formula'}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{matchingChartSeries.length > 1 && (
|
||||||
|
<Select
|
||||||
|
value={selectedBreakdownId || ''}
|
||||||
|
onValueChange={(value) => setSelectedBreakdownId(value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="flex-1">
|
||||||
|
<SelectValue placeholder="Select Breakdown" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{matchingChartSeries
|
||||||
|
.sort((a, b) => b.metrics.sum - a.metrics.sum)
|
||||||
|
.map((serie) => (
|
||||||
|
<SelectItem key={serie.id} value={serie.id}>
|
||||||
|
{Object.values(serie.event.breakdowns ?? {}).join(
|
||||||
|
', ',
|
||||||
|
)}
|
||||||
|
<DropdownMenuShortcut className="ml-auto">
|
||||||
|
({serie.data.find((d) => d.date === date)?.count})
|
||||||
|
</DropdownMenuShortcut>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="col">
|
||||||
|
{profilesQuery.isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<div className="text-muted-foreground">Loading users...</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ProfileList profiles={profiles} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollableModal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Funnel-specific props and component
|
||||||
|
interface FunnelUsersViewProps {
|
||||||
|
report: IChartInput;
|
||||||
|
stepIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function FunnelUsersView({ report, stepIndex }: FunnelUsersViewProps) {
|
||||||
|
const trpc = useTRPC();
|
||||||
|
const [showDropoffs, setShowDropoffs] = useState(false);
|
||||||
|
|
||||||
|
const profilesQuery = useQuery(
|
||||||
|
trpc.chart.getFunnelProfiles.queryOptions(
|
||||||
|
{
|
||||||
|
projectId: report.projectId,
|
||||||
|
startDate: report.startDate!,
|
||||||
|
endDate: report.endDate!,
|
||||||
|
range: report.range,
|
||||||
|
series: report.series,
|
||||||
|
stepIndex: stepIndex,
|
||||||
|
showDropoffs: showDropoffs,
|
||||||
|
funnelWindow: report.funnelWindow,
|
||||||
|
funnelGroup: report.funnelGroup,
|
||||||
|
breakdowns: report.breakdowns,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: stepIndex !== undefined,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const profiles = profilesQuery.data ?? [];
|
||||||
|
const isLastStep = stepIndex === report.series.length - 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollableModal
|
||||||
|
header={
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<ModalHeader
|
||||||
|
title="View Users"
|
||||||
|
text={
|
||||||
|
showDropoffs
|
||||||
|
? `Users who dropped off after step ${stepIndex + 1} of ${report.series.length}`
|
||||||
|
: `Users who completed step ${stepIndex + 1} of ${report.series.length} in the funnel`
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{!isLastStep && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowDropoffs(false)}
|
||||||
|
className={cn(
|
||||||
|
'px-3 py-1.5 text-sm rounded-md transition-colors',
|
||||||
|
!showDropoffs
|
||||||
|
? 'bg-primary text-primary-foreground'
|
||||||
|
: 'bg-muted text-muted-foreground hover:bg-muted/80',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Completed
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowDropoffs(true)}
|
||||||
|
className={cn(
|
||||||
|
'px-3 py-1.5 text-sm rounded-md transition-colors',
|
||||||
|
showDropoffs
|
||||||
|
? 'bg-primary text-primary-foreground'
|
||||||
|
: 'bg-muted text-muted-foreground hover:bg-muted/80',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Dropped Off
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{profilesQuery.isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<div className="text-muted-foreground">Loading users...</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ProfileList profiles={profiles} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollableModal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Union type for props
|
||||||
|
type ViewChartUsersProps =
|
||||||
|
| {
|
||||||
|
type: 'chart';
|
||||||
|
chartData: IChartData;
|
||||||
|
report: IChartInput;
|
||||||
|
date: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'funnel';
|
||||||
|
report: IChartInput;
|
||||||
|
stepIndex: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Main component that routes to the appropriate view
|
||||||
|
export default function ViewChartUsers(props: ViewChartUsersProps) {
|
||||||
|
if (props.type === 'funnel') {
|
||||||
|
return (
|
||||||
|
<FunnelUsersView report={props.report} stepIndex={props.stepIndex} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChartUsersView
|
||||||
|
chartData={props.chartData}
|
||||||
|
report={props.report}
|
||||||
|
date={props.date}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -69,6 +71,11 @@ function Component() {
|
|||||||
</WidgetHead>
|
</WidgetHead>
|
||||||
<WidgetBody>
|
<WidgetBody>
|
||||||
<ReportChartShortcut
|
<ReportChartShortcut
|
||||||
|
options={{
|
||||||
|
renderSerieName(names) {
|
||||||
|
return names[1];
|
||||||
|
},
|
||||||
|
}}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
range="30d"
|
range="30d"
|
||||||
chartType="pie"
|
chartType="pie"
|
||||||
@@ -78,7 +85,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 +93,7 @@ function Component() {
|
|||||||
displayName: name,
|
displayName: name,
|
||||||
segment: 'event',
|
segment: 'event',
|
||||||
filters: filters ?? [],
|
filters: filters ?? [],
|
||||||
|
type: 'event',
|
||||||
}))
|
}))
|
||||||
: [
|
: [
|
||||||
{
|
{
|
||||||
@@ -94,6 +102,7 @@ function Component() {
|
|||||||
displayName: 'All events',
|
displayName: 'All events',
|
||||||
segment: 'event',
|
segment: 'event',
|
||||||
filters: filters ?? [],
|
filters: filters ?? [],
|
||||||
|
type: 'event',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -106,6 +115,11 @@ function Component() {
|
|||||||
</WidgetHead>
|
</WidgetHead>
|
||||||
<WidgetBody>
|
<WidgetBody>
|
||||||
<ReportChartShortcut
|
<ReportChartShortcut
|
||||||
|
options={{
|
||||||
|
renderSerieName(names) {
|
||||||
|
return names[1];
|
||||||
|
},
|
||||||
|
}}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
range="30d"
|
range="30d"
|
||||||
chartType="bar"
|
chartType="bar"
|
||||||
@@ -115,7 +129,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 +137,7 @@ function Component() {
|
|||||||
displayName: name,
|
displayName: name,
|
||||||
segment: 'event',
|
segment: 'event',
|
||||||
filters: filters ?? [],
|
filters: filters ?? [],
|
||||||
|
type: 'event',
|
||||||
}))
|
}))
|
||||||
: [
|
: [
|
||||||
{
|
{
|
||||||
@@ -131,6 +146,7 @@ function Component() {
|
|||||||
displayName: 'All events',
|
displayName: 'All events',
|
||||||
segment: 'event',
|
segment: 'event',
|
||||||
filters: filters ?? [],
|
filters: filters ?? [],
|
||||||
|
type: 'event',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -143,6 +159,11 @@ function Component() {
|
|||||||
</WidgetHead>
|
</WidgetHead>
|
||||||
<WidgetBody>
|
<WidgetBody>
|
||||||
<ReportChartShortcut
|
<ReportChartShortcut
|
||||||
|
options={{
|
||||||
|
renderSerieName(names) {
|
||||||
|
return names[1];
|
||||||
|
},
|
||||||
|
}}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
range="30d"
|
range="30d"
|
||||||
chartType="linear"
|
chartType="linear"
|
||||||
@@ -152,7 +173,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 +181,7 @@ function Component() {
|
|||||||
displayName: name,
|
displayName: name,
|
||||||
segment: 'event',
|
segment: 'event',
|
||||||
filters: filters ?? [],
|
filters: filters ?? [],
|
||||||
|
type: 'event',
|
||||||
}))
|
}))
|
||||||
: [
|
: [
|
||||||
{
|
{
|
||||||
@@ -168,6 +190,7 @@ function Component() {
|
|||||||
displayName: 'All events',
|
displayName: 'All events',
|
||||||
segment: 'event',
|
segment: 'event',
|
||||||
filters: filters ?? [],
|
filters: filters ?? [],
|
||||||
|
type: 'event',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,20 +24,18 @@ import { createFileRoute } from '@tanstack/react-router';
|
|||||||
import { parseAsInteger, useQueryState } from 'nuqs';
|
import { parseAsInteger, useQueryState } from 'nuqs';
|
||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
|
|
||||||
export const Route = createFileRoute('/_app/$organizationId/$projectId/pages')(
|
export const Route = createFileRoute('/_app/$organizationId/$projectId/pages')({
|
||||||
{
|
component: Component,
|
||||||
component: Component,
|
head: () => {
|
||||||
head: () => {
|
return {
|
||||||
return {
|
meta: [
|
||||||
meta: [
|
{
|
||||||
{
|
title: createProjectTitle(PAGE_TITLES.PAGES),
|
||||||
title: createProjectTitle(PAGE_TITLES.PAGES),
|
},
|
||||||
},
|
],
|
||||||
],
|
};
|
||||||
};
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
);
|
});
|
||||||
|
|
||||||
function Component() {
|
function Component() {
|
||||||
const { projectId } = Route.useParams();
|
const { projectId } = Route.useParams();
|
||||||
@@ -220,8 +218,9 @@ const PageCard = memo(
|
|||||||
|
|
||||||
chartType: 'linear',
|
chartType: 'linear',
|
||||||
projectId,
|
projectId,
|
||||||
events: [
|
series: [
|
||||||
{
|
{
|
||||||
|
type: 'event',
|
||||||
id: 'A',
|
id: 'A',
|
||||||
name: 'screen_view',
|
name: 'screen_view',
|
||||||
segment: 'event',
|
segment: 'event',
|
||||||
|
|||||||
@@ -339,4 +339,38 @@ button {
|
|||||||
|
|
||||||
.animate-ping-slow {
|
.animate-ping-slow {
|
||||||
animation: ping-slow 2s cubic-bezier(0, 0, 0.2, 1) infinite;
|
animation: ping-slow 2s cubic-bezier(0, 0, 0.2, 1) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollarea {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 300px; /* set any height */
|
||||||
|
overflow: hidden; /* hide native scrollbars */
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollarea-content {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
overflow-y: scroll;
|
||||||
|
padding-right: 16px; /* preserve space for custom scrollbar */
|
||||||
|
scrollbar-width: none; /* hide Firefox scrollbar */
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollarea-content::-webkit-scrollbar {
|
||||||
|
width: 8px; /* size of custom scrollbar */
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollarea-content::-webkit-scrollbar-track {
|
||||||
|
background: transparent; /* no visible track, like shadcn */
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollarea-content::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
border-radius: 9999px; /* fully rounded */
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollarea-content:hover::-webkit-scrollbar-thumb,
|
||||||
|
.scrollarea-content:active::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(0, 0, 0, 0.4); /* darken on hover/scroll */
|
||||||
}
|
}
|
||||||
@@ -23,9 +23,12 @@ const chartColors = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export function getChartColor(index: number): string {
|
export function getChartColor(index: number): string {
|
||||||
return chartColors[index % chartColors.length]!.main;
|
return chartColors[index % chartColors.length]?.main || chartColors[0].main;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getChartTranslucentColor(index: number): string {
|
export function getChartTranslucentColor(index: number): string {
|
||||||
return chartColors[index % chartColors.length]!.translucent;
|
return (
|
||||||
|
chartColors[index % chartColors.length]?.translucent ||
|
||||||
|
chartColors[0].translucent
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
104
packages/db/code-migrations/7-migrate-events-to-series.ts
Normal file
104
packages/db/code-migrations/7-migrate-events-to-series.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { shortId } from '@openpanel/common';
|
||||||
|
import type {
|
||||||
|
IChartEvent,
|
||||||
|
IChartEventItem,
|
||||||
|
IChartFormula,
|
||||||
|
} from '@openpanel/validation';
|
||||||
|
import { db } from '../index';
|
||||||
|
import { printBoxMessage } from './helpers';
|
||||||
|
|
||||||
|
export async function up() {
|
||||||
|
printBoxMessage('🔄 Migrating Events to Series Format', []);
|
||||||
|
|
||||||
|
// Get all reports
|
||||||
|
const reports = await db.report.findMany({
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
events: true,
|
||||||
|
formula: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let migratedCount = 0;
|
||||||
|
let skippedCount = 0;
|
||||||
|
let formulaAddedCount = 0;
|
||||||
|
|
||||||
|
for (const report of reports) {
|
||||||
|
const events = report.events as unknown as Array<
|
||||||
|
Partial<IChartEventItem> | Partial<IChartEvent>
|
||||||
|
>;
|
||||||
|
const oldFormula = report.formula;
|
||||||
|
|
||||||
|
// Check if any event is missing the 'type' field (old format)
|
||||||
|
const needsEventMigration =
|
||||||
|
Array.isArray(events) &&
|
||||||
|
events.length > 0 &&
|
||||||
|
events.some(
|
||||||
|
(event) => !event || typeof event !== 'object' || !('type' in event),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if formula exists and isn't already in the series
|
||||||
|
const hasFormulaInSeries =
|
||||||
|
Array.isArray(events) &&
|
||||||
|
events.some(
|
||||||
|
(item) =>
|
||||||
|
item &&
|
||||||
|
typeof item === 'object' &&
|
||||||
|
'type' in item &&
|
||||||
|
item.type === 'formula',
|
||||||
|
);
|
||||||
|
|
||||||
|
const needsFormulaMigration = !!oldFormula && !hasFormulaInSeries;
|
||||||
|
|
||||||
|
// Skip if no migration needed
|
||||||
|
if (!needsEventMigration && !needsFormulaMigration) {
|
||||||
|
skippedCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transform events to new format: add type: 'event' to each event
|
||||||
|
const migratedSeries: IChartEventItem[] = Array.isArray(events)
|
||||||
|
? events.map((event) => {
|
||||||
|
if (event && typeof event === 'object' && 'type' in event) {
|
||||||
|
return event as IChartEventItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...event,
|
||||||
|
type: 'event',
|
||||||
|
} as IChartEventItem;
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// Add formula to series if it exists and isn't already there
|
||||||
|
if (needsFormulaMigration && oldFormula) {
|
||||||
|
const formulaItem: IChartFormula = {
|
||||||
|
type: 'formula',
|
||||||
|
formula: oldFormula,
|
||||||
|
id: shortId(),
|
||||||
|
};
|
||||||
|
migratedSeries.push(formulaItem);
|
||||||
|
formulaAddedCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`Updating report ${report.name} (${report.id}) with ${migratedSeries.length} series`,
|
||||||
|
);
|
||||||
|
// Update the report with migrated series
|
||||||
|
await db.report.update({
|
||||||
|
where: { id: report.id },
|
||||||
|
data: {
|
||||||
|
events: migratedSeries,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
migratedCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
printBoxMessage('✅ Migration Complete', [
|
||||||
|
`Migrated: ${migratedCount} reports`,
|
||||||
|
`Formulas added: ${formulaAddedCount} reports`,
|
||||||
|
`Skipped: ${skippedCount} reports (already in new format or empty)`,
|
||||||
|
]);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ export * from './src/clickhouse/client';
|
|||||||
export * from './src/clickhouse/csv';
|
export * from './src/clickhouse/csv';
|
||||||
export * from './src/sql-builder';
|
export * from './src/sql-builder';
|
||||||
export * from './src/services/chart.service';
|
export * from './src/services/chart.service';
|
||||||
|
export * from './src/engine';
|
||||||
export * from './src/services/clients.service';
|
export * from './src/services/clients.service';
|
||||||
export * from './src/services/dashboard.service';
|
export * from './src/services/dashboard.service';
|
||||||
export * from './src/services/event.service';
|
export * from './src/services/event.service';
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
"@prisma/extension-read-replicas": "^0.4.1",
|
"@prisma/extension-read-replicas": "^0.4.1",
|
||||||
"fast-deep-equal": "^3.1.3",
|
"fast-deep-equal": "^3.1.3",
|
||||||
"jiti": "^2.4.1",
|
"jiti": "^2.4.1",
|
||||||
|
"mathjs": "^12.3.2",
|
||||||
"prisma-json-types-generator": "^3.1.1",
|
"prisma-json-types-generator": "^3.1.1",
|
||||||
"ramda": "^0.29.1",
|
"ramda": "^0.29.1",
|
||||||
"sqlstring": "^2.3.3",
|
"sqlstring": "^2.3.3",
|
||||||
|
|||||||
112
packages/db/scripts/ch-copy-from-remote.ts
Normal file
112
packages/db/scripts/ch-copy-from-remote.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { stdin as input, stdout as output } from 'node:process';
|
||||||
|
import { createInterface } from 'node:readline/promises';
|
||||||
|
import { parseArgs } from 'node:util';
|
||||||
|
import sqlstring from 'sqlstring';
|
||||||
|
import { ch } from '../src/clickhouse/client';
|
||||||
|
import { clix } from '../src/clickhouse/query-builder';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const rl = createInterface({ input, output });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { values } = parseArgs({
|
||||||
|
args: process.argv.slice(2),
|
||||||
|
options: {
|
||||||
|
host: { type: 'string' },
|
||||||
|
user: { type: 'string' },
|
||||||
|
password: { type: 'string' },
|
||||||
|
db: { type: 'string' },
|
||||||
|
start: { type: 'string' },
|
||||||
|
end: { type: 'string' },
|
||||||
|
projects: { type: 'string' },
|
||||||
|
},
|
||||||
|
strict: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const getArg = (val: unknown): string | undefined =>
|
||||||
|
typeof val === 'string' ? val : undefined;
|
||||||
|
|
||||||
|
console.log('Copy data from remote ClickHouse to local');
|
||||||
|
console.log('---------------------------------------');
|
||||||
|
|
||||||
|
const host =
|
||||||
|
getArg(values.host) || (await rl.question('Remote Host (IP/Domain): '));
|
||||||
|
if (!host) throw new Error('Host is required');
|
||||||
|
|
||||||
|
const user = getArg(values.user) || (await rl.question('Remote User: '));
|
||||||
|
if (!user) throw new Error('User is required');
|
||||||
|
|
||||||
|
const password =
|
||||||
|
getArg(values.password) || (await rl.question('Remote Password: '));
|
||||||
|
if (!password) throw new Error('Password is required');
|
||||||
|
|
||||||
|
const dbName =
|
||||||
|
getArg(values.db) ||
|
||||||
|
(await rl.question('Remote DB Name (default: openpanel): ')) ||
|
||||||
|
'openpanel';
|
||||||
|
|
||||||
|
const startDate =
|
||||||
|
getArg(values.start) ||
|
||||||
|
(await rl.question('Start Date (YYYY-MM-DD HH:mm:ss): '));
|
||||||
|
if (!startDate) throw new Error('Start date is required');
|
||||||
|
|
||||||
|
const endDate =
|
||||||
|
getArg(values.end) ||
|
||||||
|
(await rl.question('End Date (YYYY-MM-DD HH:mm:ss): '));
|
||||||
|
if (!endDate) throw new Error('End date is required');
|
||||||
|
|
||||||
|
const projectIdsInput =
|
||||||
|
getArg(values.projects) ||
|
||||||
|
(await rl.question(
|
||||||
|
'Project IDs (comma separated, leave empty for all): ',
|
||||||
|
));
|
||||||
|
const projectIds = projectIdsInput
|
||||||
|
? projectIdsInput.split(',').map((s: string) => s.trim())
|
||||||
|
: [];
|
||||||
|
|
||||||
|
console.log('\nStarting copy process...');
|
||||||
|
|
||||||
|
const tables = ['sessions', 'events'];
|
||||||
|
|
||||||
|
for (const table of tables) {
|
||||||
|
console.log(`Processing table: ${table}`);
|
||||||
|
|
||||||
|
// Build the SELECT part using the query builder
|
||||||
|
// We use sqlstring to escape the remote function arguments
|
||||||
|
const remoteTable = `remote(${sqlstring.escape(host)}, ${sqlstring.escape(dbName)}, ${sqlstring.escape(table)}, ${sqlstring.escape(user)}, ${sqlstring.escape(password)})`;
|
||||||
|
|
||||||
|
const queryBuilder = clix(ch)
|
||||||
|
.from(remoteTable)
|
||||||
|
.select(['*'])
|
||||||
|
.where('created_at', 'BETWEEN', [startDate, endDate]);
|
||||||
|
|
||||||
|
if (projectIds.length > 0) {
|
||||||
|
queryBuilder.where('project_id', 'IN', projectIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectQuery = queryBuilder.toSQL();
|
||||||
|
const insertQuery = `INSERT INTO ${dbName}.${table} ${selectQuery}`;
|
||||||
|
|
||||||
|
console.log(`Executing: ${insertQuery}`);
|
||||||
|
|
||||||
|
// try {
|
||||||
|
// await ch.command({
|
||||||
|
// query: insertQuery,
|
||||||
|
// });
|
||||||
|
// console.log(`✅ Copied ${table} successfully`);
|
||||||
|
// } catch (error) {
|
||||||
|
// console.error(`❌ Failed to copy ${table}:`, error);
|
||||||
|
// }
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\nDone!');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('\nError:', error);
|
||||||
|
} finally {
|
||||||
|
rl.close();
|
||||||
|
await ch.close();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
96
packages/db/scripts/ch-update-sessions-with-revenue.ts
Normal file
96
packages/db/scripts/ch-update-sessions-with-revenue.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { TABLE_NAMES, ch } from '../src/clickhouse/client';
|
||||||
|
import { clix } from '../src/clickhouse/query-builder';
|
||||||
|
|
||||||
|
const START_DATE = new Date('2025-11-10T00:00:00Z');
|
||||||
|
const END_DATE = new Date('2025-11-20T23:00:00Z');
|
||||||
|
const SESSIONS_PER_HOUR = 2;
|
||||||
|
|
||||||
|
// Revenue between $10 (1000 cents) and $200 (20000 cents)
|
||||||
|
const MIN_REVENUE = 1000;
|
||||||
|
const MAX_REVENUE = 20000;
|
||||||
|
|
||||||
|
function getRandomRevenue() {
|
||||||
|
return (
|
||||||
|
Math.floor(Math.random() * (MAX_REVENUE - MIN_REVENUE + 1)) + MIN_REVENUE
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log(
|
||||||
|
`Starting revenue update for sessions between ${START_DATE.toISOString()} and ${END_DATE.toISOString()}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
let currentDate = new Date(START_DATE);
|
||||||
|
|
||||||
|
while (currentDate < END_DATE) {
|
||||||
|
const nextHour = new Date(currentDate.getTime() + 60 * 60 * 1000);
|
||||||
|
console.log(`Processing hour: ${currentDate.toISOString()}`);
|
||||||
|
|
||||||
|
// 1. Pick random sessions for this hour
|
||||||
|
const sessions = await clix(ch)
|
||||||
|
.from(TABLE_NAMES.sessions)
|
||||||
|
.select(['id'])
|
||||||
|
.where('created_at', '>=', currentDate)
|
||||||
|
.andWhere('created_at', '<', nextHour)
|
||||||
|
.where('project_id', '=', 'public-web')
|
||||||
|
.limit(SESSIONS_PER_HOUR)
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
if (sessions.length === 0) {
|
||||||
|
console.log(`No sessions found for ${currentDate.toISOString()}`);
|
||||||
|
currentDate = nextHour;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionIds = sessions.map((s: any) => s.id);
|
||||||
|
console.log(
|
||||||
|
`Found ${sessionIds.length} sessions to update: ${sessionIds.join(', ')}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Construct update query
|
||||||
|
// We want to assign a DIFFERENT random revenue to each session
|
||||||
|
// Query: ALTER TABLE sessions UPDATE revenue = if(id='id1', rev1, if(id='id2', rev2, ...)) WHERE id IN ('id1', 'id2', ...)
|
||||||
|
|
||||||
|
const updates: { id: string; revenue: number }[] = [];
|
||||||
|
|
||||||
|
for (const id of sessionIds) {
|
||||||
|
const revenue = getRandomRevenue();
|
||||||
|
updates.push({ id, revenue });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build nested if() for the update expression
|
||||||
|
// ClickHouse doesn't have CASE WHEN in UPDATE expression in the same way, but if() works.
|
||||||
|
// Actually multiIf is cleaner: multiIf(id='id1', rev1, id='id2', rev2, revenue)
|
||||||
|
|
||||||
|
const conditions = updates
|
||||||
|
.map((u) => `id = '${u.id}', ${u.revenue}`)
|
||||||
|
.join(', ');
|
||||||
|
const updateExpr = `multiIf(${conditions}, revenue)`;
|
||||||
|
|
||||||
|
const idsStr = sessionIds.map((id: string) => `'${id}'`).join(', ');
|
||||||
|
const query = `ALTER TABLE ${TABLE_NAMES.sessions} UPDATE revenue = ${updateExpr} WHERE id IN (${idsStr})`;
|
||||||
|
|
||||||
|
console.log(`Executing update: ${query}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ch.command({
|
||||||
|
query,
|
||||||
|
});
|
||||||
|
console.log('Update command sent.');
|
||||||
|
|
||||||
|
// Wait a bit to not overload mutations if running on a large range
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update sessions:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
currentDate = nextHour;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Done!');
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error('Script failed:', error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -731,6 +731,7 @@ clix.toInterval = (node: string, interval: IInterval) => {
|
|||||||
};
|
};
|
||||||
clix.toDate = (node: string, interval: IInterval) => {
|
clix.toDate = (node: string, interval: IInterval) => {
|
||||||
switch (interval) {
|
switch (interval) {
|
||||||
|
case 'day':
|
||||||
case 'week':
|
case 'week':
|
||||||
case 'month': {
|
case 'month': {
|
||||||
return `toDate(${node})`;
|
return `toDate(${node})`;
|
||||||
|
|||||||
216
packages/db/src/engine/compute.ts
Normal file
216
packages/db/src/engine/compute.ts
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
import { round } from '@openpanel/common';
|
||||||
|
import { alphabetIds } from '@openpanel/constants';
|
||||||
|
import type { IChartFormula } from '@openpanel/validation';
|
||||||
|
import * as mathjs from 'mathjs';
|
||||||
|
import type { ConcreteSeries } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute formula series from fetched event series
|
||||||
|
* Formulas reference event series using alphabet IDs (A, B, C, etc.)
|
||||||
|
*/
|
||||||
|
export function compute(
|
||||||
|
fetchedSeries: ConcreteSeries[],
|
||||||
|
definitions: Array<{
|
||||||
|
type: 'event' | 'formula';
|
||||||
|
id?: string;
|
||||||
|
formula?: string;
|
||||||
|
}>,
|
||||||
|
): ConcreteSeries[] {
|
||||||
|
const results: ConcreteSeries[] = [...fetchedSeries];
|
||||||
|
|
||||||
|
// Process formulas in order (they can reference previous formulas)
|
||||||
|
definitions.forEach((definition, formulaIndex) => {
|
||||||
|
if (definition.type !== 'formula') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formula = definition as IChartFormula;
|
||||||
|
if (!formula.formula) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group ALL series (events + previously computed formulas) by breakdown signature
|
||||||
|
// Series with the same breakdown values should be computed together
|
||||||
|
const seriesByBreakdown = new Map<string, ConcreteSeries[]>();
|
||||||
|
|
||||||
|
// Include both fetched event series AND previously computed formulas
|
||||||
|
const allSeries = [
|
||||||
|
...fetchedSeries,
|
||||||
|
...results.filter((s) => s.definitionIndex < formulaIndex),
|
||||||
|
];
|
||||||
|
|
||||||
|
allSeries.forEach((serie) => {
|
||||||
|
// Create breakdown signature: skip first name part (event/formula name) and use breakdown values
|
||||||
|
// If name.length === 1, it means no breakdowns (just event name)
|
||||||
|
// If name.length > 1, name[0] is event name, name[1+] are breakdown values
|
||||||
|
const breakdownSignature =
|
||||||
|
serie.name.length > 1 ? serie.name.slice(1).join(':::') : '';
|
||||||
|
|
||||||
|
if (!seriesByBreakdown.has(breakdownSignature)) {
|
||||||
|
seriesByBreakdown.set(breakdownSignature, []);
|
||||||
|
}
|
||||||
|
seriesByBreakdown.get(breakdownSignature)!.push(serie);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Compute formula for each breakdown group
|
||||||
|
for (const [breakdownSignature, breakdownSeries] of seriesByBreakdown) {
|
||||||
|
// Map series by their definition index for formula evaluation
|
||||||
|
const seriesByIndex = new Map<number, ConcreteSeries>();
|
||||||
|
breakdownSeries.forEach((serie) => {
|
||||||
|
seriesByIndex.set(serie.definitionIndex, serie);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get all unique dates across all series in this breakdown group
|
||||||
|
const allDates = new Set<string>();
|
||||||
|
breakdownSeries.forEach((serie) => {
|
||||||
|
serie.data.forEach((item) => {
|
||||||
|
allDates.add(item.date);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const sortedDates = Array.from(allDates).sort(
|
||||||
|
(a, b) => new Date(a).getTime() - new Date(b).getTime(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Calculate total_count for the formula using the same formula applied to input series' total_count values
|
||||||
|
// total_count is constant across all dates for a breakdown group, so compute it once
|
||||||
|
const totalCountScope: Record<string, number> = {};
|
||||||
|
definitions.slice(0, formulaIndex).forEach((depDef, depIndex) => {
|
||||||
|
const readableId = alphabetIds[depIndex];
|
||||||
|
if (!readableId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the series for this dependency in the current breakdown group
|
||||||
|
const depSeries = seriesByIndex.get(depIndex);
|
||||||
|
if (depSeries) {
|
||||||
|
// Get total_count from any data point (it's the same for all dates)
|
||||||
|
const totalCount = depSeries.data.find(
|
||||||
|
(d) => d.total_count != null,
|
||||||
|
)?.total_count;
|
||||||
|
totalCountScope[readableId] = totalCount ?? 0;
|
||||||
|
} else {
|
||||||
|
// Could be a formula from a previous breakdown group - find it in results
|
||||||
|
const formulaSerie = results.find(
|
||||||
|
(s) =>
|
||||||
|
s.definitionIndex === depIndex &&
|
||||||
|
'type' in s.definition &&
|
||||||
|
s.definition.type === 'formula' &&
|
||||||
|
s.name.slice(1).join(':::') === breakdownSignature,
|
||||||
|
);
|
||||||
|
if (formulaSerie) {
|
||||||
|
const totalCount = formulaSerie.data.find(
|
||||||
|
(d) => d.total_count != null,
|
||||||
|
)?.total_count;
|
||||||
|
totalCountScope[readableId] = totalCount ?? 0;
|
||||||
|
} else {
|
||||||
|
totalCountScope[readableId] = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Evaluate formula for total_count
|
||||||
|
let formulaTotalCount: number | undefined;
|
||||||
|
try {
|
||||||
|
const result = mathjs
|
||||||
|
.parse(formula.formula)
|
||||||
|
.compile()
|
||||||
|
.evaluate(totalCountScope) as number;
|
||||||
|
formulaTotalCount =
|
||||||
|
Number.isNaN(result) || !Number.isFinite(result)
|
||||||
|
? undefined
|
||||||
|
: round(result, 2);
|
||||||
|
} catch (error) {
|
||||||
|
formulaTotalCount = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate formula for each date
|
||||||
|
const formulaData = sortedDates.map((date) => {
|
||||||
|
const scope: Record<string, number> = {};
|
||||||
|
|
||||||
|
// Build scope using alphabet IDs (A, B, C, etc.)
|
||||||
|
definitions.slice(0, formulaIndex).forEach((depDef, depIndex) => {
|
||||||
|
const readableId = alphabetIds[depIndex];
|
||||||
|
if (!readableId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the series for this dependency in the current breakdown group
|
||||||
|
const depSeries = seriesByIndex.get(depIndex);
|
||||||
|
if (depSeries) {
|
||||||
|
const dataPoint = depSeries.data.find((d) => d.date === date);
|
||||||
|
scope[readableId] = dataPoint?.count ?? 0;
|
||||||
|
} else {
|
||||||
|
// Could be a formula from a previous breakdown group - find it in results
|
||||||
|
// Match by definitionIndex AND breakdown signature
|
||||||
|
const formulaSerie = results.find(
|
||||||
|
(s) =>
|
||||||
|
s.definitionIndex === depIndex &&
|
||||||
|
'type' in s.definition &&
|
||||||
|
s.definition.type === 'formula' &&
|
||||||
|
s.name.slice(1).join(':::') === breakdownSignature,
|
||||||
|
);
|
||||||
|
if (formulaSerie) {
|
||||||
|
const dataPoint = formulaSerie.data.find((d) => d.date === date);
|
||||||
|
scope[readableId] = dataPoint?.count ?? 0;
|
||||||
|
} else {
|
||||||
|
scope[readableId] = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Evaluate formula
|
||||||
|
let count: number;
|
||||||
|
try {
|
||||||
|
count = mathjs
|
||||||
|
.parse(formula.formula)
|
||||||
|
.compile()
|
||||||
|
.evaluate(scope) as number;
|
||||||
|
} catch (error) {
|
||||||
|
count = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
date,
|
||||||
|
count:
|
||||||
|
Number.isNaN(count) || !Number.isFinite(count)
|
||||||
|
? 0
|
||||||
|
: round(count, 2),
|
||||||
|
total_count: formulaTotalCount,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create concrete series for this formula
|
||||||
|
const templateSerie = breakdownSeries[0]!;
|
||||||
|
|
||||||
|
// Extract breakdown values from template series name
|
||||||
|
// name[0] is event/formula name, name[1+] are breakdown values
|
||||||
|
const breakdownValues =
|
||||||
|
templateSerie.name.length > 1 ? templateSerie.name.slice(1) : [];
|
||||||
|
|
||||||
|
const formulaName =
|
||||||
|
breakdownValues.length > 0
|
||||||
|
? [formula.displayName || formula.formula, ...breakdownValues]
|
||||||
|
: [formula.displayName || formula.formula];
|
||||||
|
|
||||||
|
const formulaSeries: ConcreteSeries = {
|
||||||
|
id: `formula-${formula.id ?? formulaIndex}-${breakdownSignature || 'default'}`,
|
||||||
|
definitionId:
|
||||||
|
formula.id ?? alphabetIds[formulaIndex] ?? `formula-${formulaIndex}`,
|
||||||
|
definitionIndex: formulaIndex,
|
||||||
|
name: formulaName,
|
||||||
|
context: {
|
||||||
|
filters: templateSerie.context.filters,
|
||||||
|
breakdownValue: templateSerie.context.breakdownValue,
|
||||||
|
breakdowns: templateSerie.context.breakdowns,
|
||||||
|
},
|
||||||
|
data: formulaData,
|
||||||
|
definition: formula,
|
||||||
|
};
|
||||||
|
|
||||||
|
results.push(formulaSeries);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
151
packages/db/src/engine/fetch.ts
Normal file
151
packages/db/src/engine/fetch.ts
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
import type { ISerieDataItem } from '@openpanel/common';
|
||||||
|
import { groupByLabels } from '@openpanel/common';
|
||||||
|
import { alphabetIds } from '@openpanel/constants';
|
||||||
|
import type { IGetChartDataInput } from '@openpanel/validation';
|
||||||
|
import { chQuery } from '../clickhouse/client';
|
||||||
|
import { getChartSql } from '../services/chart.service';
|
||||||
|
import type { ConcreteSeries, Plan } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch data for all event series in the plan
|
||||||
|
* This handles breakdown expansion automatically via groupByLabels
|
||||||
|
*/
|
||||||
|
export async function fetch(plan: Plan): Promise<ConcreteSeries[]> {
|
||||||
|
const results: ConcreteSeries[] = [];
|
||||||
|
|
||||||
|
// Process each event definition
|
||||||
|
for (let i = 0; i < plan.definitions.length; i++) {
|
||||||
|
const definition = plan.definitions[i]!;
|
||||||
|
|
||||||
|
if (definition.type !== 'event') {
|
||||||
|
// Skip formulas - they'll be handled in compute stage
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = definition as typeof definition & { type: 'event' };
|
||||||
|
|
||||||
|
// Find the corresponding concrete series placeholder
|
||||||
|
const placeholder = plan.concreteSeries.find(
|
||||||
|
(cs) => cs.definitionId === definition.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!placeholder) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build query input
|
||||||
|
const queryInput: IGetChartDataInput = {
|
||||||
|
event: {
|
||||||
|
id: event.id,
|
||||||
|
name: event.name,
|
||||||
|
segment: event.segment,
|
||||||
|
filters: event.filters,
|
||||||
|
displayName: event.displayName,
|
||||||
|
property: event.property,
|
||||||
|
},
|
||||||
|
projectId: plan.input.projectId,
|
||||||
|
startDate: plan.input.startDate,
|
||||||
|
endDate: plan.input.endDate,
|
||||||
|
breakdowns: plan.input.breakdowns,
|
||||||
|
interval: plan.input.interval,
|
||||||
|
chartType: plan.input.chartType,
|
||||||
|
metric: plan.input.metric,
|
||||||
|
previous: plan.input.previous ?? false,
|
||||||
|
limit: plan.input.limit,
|
||||||
|
offset: plan.input.offset,
|
||||||
|
criteria: plan.input.criteria,
|
||||||
|
funnelGroup: plan.input.funnelGroup,
|
||||||
|
funnelWindow: plan.input.funnelWindow,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Execute query
|
||||||
|
let queryResult = await chQuery<ISerieDataItem>(
|
||||||
|
getChartSql({ ...queryInput, timezone: plan.timezone }),
|
||||||
|
{
|
||||||
|
session_timezone: plan.timezone,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fallback: if no results with breakdowns, try without breakdowns
|
||||||
|
if (queryResult.length === 0 && plan.input.breakdowns.length > 0) {
|
||||||
|
queryResult = await chQuery<ISerieDataItem>(
|
||||||
|
getChartSql({
|
||||||
|
...queryInput,
|
||||||
|
breakdowns: [],
|
||||||
|
timezone: plan.timezone,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
session_timezone: plan.timezone,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group by labels (handles breakdown expansion)
|
||||||
|
const groupedSeries = groupByLabels(queryResult);
|
||||||
|
|
||||||
|
// Create concrete series for each grouped result
|
||||||
|
groupedSeries.forEach((grouped) => {
|
||||||
|
// Extract breakdown value from name array
|
||||||
|
// If breakdowns exist, name[0] is event name, name[1+] are breakdown values
|
||||||
|
const breakdownValue =
|
||||||
|
plan.input.breakdowns.length > 0 && grouped.name.length > 1
|
||||||
|
? grouped.name.slice(1).join(' - ')
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
// Build breakdowns object: { country: 'SE', path: '/ewoqmepwq' }
|
||||||
|
const breakdowns: Record<string, string> | undefined =
|
||||||
|
plan.input.breakdowns.length > 0 && grouped.name.length > 1
|
||||||
|
? {}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (breakdowns) {
|
||||||
|
plan.input.breakdowns.forEach((breakdown, idx) => {
|
||||||
|
const breakdownNamePart = grouped.name[idx + 1];
|
||||||
|
if (breakdownNamePart) {
|
||||||
|
breakdowns[breakdown.name] = breakdownNamePart;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build filters including breakdown value
|
||||||
|
const filters = [...event.filters];
|
||||||
|
if (breakdownValue && plan.input.breakdowns.length > 0) {
|
||||||
|
// Add breakdown filter
|
||||||
|
plan.input.breakdowns.forEach((breakdown, idx) => {
|
||||||
|
const breakdownNamePart = grouped.name[idx + 1];
|
||||||
|
if (breakdownNamePart) {
|
||||||
|
filters.push({
|
||||||
|
id: `breakdown-${idx}`,
|
||||||
|
name: breakdown.name,
|
||||||
|
operator: 'is',
|
||||||
|
value: [breakdownNamePart],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const concrete: ConcreteSeries = {
|
||||||
|
id: `${placeholder.id}-${grouped.name.join('-')}`,
|
||||||
|
definitionId: definition.id ?? alphabetIds[i] ?? `series-${i}`,
|
||||||
|
definitionIndex: i,
|
||||||
|
name: grouped.name,
|
||||||
|
context: {
|
||||||
|
event: event.name,
|
||||||
|
filters,
|
||||||
|
breakdownValue,
|
||||||
|
breakdowns,
|
||||||
|
},
|
||||||
|
data: grouped.data.map((item) => ({
|
||||||
|
date: item.date,
|
||||||
|
count: item.count,
|
||||||
|
total_count: item.total_count,
|
||||||
|
})),
|
||||||
|
definition,
|
||||||
|
};
|
||||||
|
|
||||||
|
results.push(concrete);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
145
packages/db/src/engine/format.ts
Normal file
145
packages/db/src/engine/format.ts
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import {
|
||||||
|
average,
|
||||||
|
getPreviousMetric,
|
||||||
|
max,
|
||||||
|
min,
|
||||||
|
round,
|
||||||
|
slug,
|
||||||
|
sum,
|
||||||
|
} from '@openpanel/common';
|
||||||
|
import { alphabetIds } from '@openpanel/constants';
|
||||||
|
import type { FinalChart } from '@openpanel/validation';
|
||||||
|
import type { ConcreteSeries } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format concrete series into FinalChart format (backward compatible)
|
||||||
|
* TODO: Migrate frontend to use cleaner ChartResponse format
|
||||||
|
*/
|
||||||
|
export function format(
|
||||||
|
concreteSeries: ConcreteSeries[],
|
||||||
|
definitions: Array<{
|
||||||
|
id?: string;
|
||||||
|
type: 'event' | 'formula';
|
||||||
|
displayName?: string;
|
||||||
|
formula?: string;
|
||||||
|
name?: string;
|
||||||
|
}>,
|
||||||
|
includeAlphaIds: boolean,
|
||||||
|
previousSeries: ConcreteSeries[] | null = null,
|
||||||
|
limit: number | undefined = undefined,
|
||||||
|
): FinalChart {
|
||||||
|
const series = concreteSeries.map((cs) => {
|
||||||
|
// Find definition for this series
|
||||||
|
const definition = definitions[cs.definitionIndex];
|
||||||
|
const alphaId = includeAlphaIds
|
||||||
|
? alphabetIds[cs.definitionIndex]
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
// Build display name with optional alpha ID
|
||||||
|
let displayName: string[];
|
||||||
|
|
||||||
|
// Replace the first name (which is the event name) with the display name if it exists
|
||||||
|
const names = cs.name.slice(0);
|
||||||
|
if (cs.definition.displayName) {
|
||||||
|
names.splice(0, 1, cs.definition.displayName);
|
||||||
|
}
|
||||||
|
// Add the alpha ID to the first name if it exists
|
||||||
|
if (alphaId) {
|
||||||
|
displayName = [`(${alphaId}) ${names[0]}`, ...names.slice(1)];
|
||||||
|
} else {
|
||||||
|
displayName = names;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate metrics for this series
|
||||||
|
const counts = cs.data.map((d) => d.count);
|
||||||
|
const metrics = {
|
||||||
|
sum: sum(counts),
|
||||||
|
average: round(average(counts), 2),
|
||||||
|
min: min(counts),
|
||||||
|
max: max(counts),
|
||||||
|
count: cs.data.find((item) => !!item.total_count)?.total_count,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build event object for compatibility
|
||||||
|
const eventName =
|
||||||
|
definition?.type === 'formula'
|
||||||
|
? definition.displayName || definition.formula || 'Formula'
|
||||||
|
: definition?.name || cs.context.event || 'unknown';
|
||||||
|
|
||||||
|
// Find matching previous series
|
||||||
|
const previousSerie = previousSeries?.find(
|
||||||
|
(ps) =>
|
||||||
|
ps.definitionIndex === cs.definitionIndex &&
|
||||||
|
ps.name.slice(1).join(':::') === cs.name.slice(1).join(':::'),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: slug(cs.id),
|
||||||
|
names: displayName,
|
||||||
|
// TODO: Do we need this now?
|
||||||
|
event: {
|
||||||
|
id: definition?.id,
|
||||||
|
name: eventName,
|
||||||
|
breakdowns: cs.context.breakdowns,
|
||||||
|
},
|
||||||
|
metrics: {
|
||||||
|
...metrics,
|
||||||
|
...(previousSerie
|
||||||
|
? {
|
||||||
|
previous: {
|
||||||
|
sum: getPreviousMetric(
|
||||||
|
metrics.sum,
|
||||||
|
sum(previousSerie.data.map((d) => d.count)),
|
||||||
|
),
|
||||||
|
average: getPreviousMetric(
|
||||||
|
metrics.average,
|
||||||
|
round(average(previousSerie.data.map((d) => d.count)), 2),
|
||||||
|
),
|
||||||
|
min: getPreviousMetric(
|
||||||
|
metrics.min,
|
||||||
|
min(previousSerie.data.map((d) => d.count)),
|
||||||
|
),
|
||||||
|
max: getPreviousMetric(
|
||||||
|
metrics.max,
|
||||||
|
max(previousSerie.data.map((d) => d.count)),
|
||||||
|
),
|
||||||
|
count: getPreviousMetric(
|
||||||
|
metrics.count ?? 0,
|
||||||
|
previousSerie.data.find((item) => !!item.total_count)
|
||||||
|
?.total_count ?? null,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
data: cs.data.map((item, index) => ({
|
||||||
|
date: item.date,
|
||||||
|
count: item.count,
|
||||||
|
previous: previousSerie?.data[index]
|
||||||
|
? getPreviousMetric(
|
||||||
|
item.count,
|
||||||
|
previousSerie.data[index]?.count ?? null,
|
||||||
|
)
|
||||||
|
: undefined,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort series by sum (biggest first)
|
||||||
|
series.sort((a, b) => b.metrics.sum - a.metrics.sum);
|
||||||
|
|
||||||
|
// Calculate global metrics
|
||||||
|
const allValues = concreteSeries.flatMap((cs) => cs.data.map((d) => d.count));
|
||||||
|
const globalMetrics = {
|
||||||
|
sum: sum(allValues),
|
||||||
|
average: round(average(allValues), 2),
|
||||||
|
min: min(allValues),
|
||||||
|
max: max(allValues),
|
||||||
|
count: undefined as number | undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
series: limit ? series.slice(0, limit) : series,
|
||||||
|
metrics: globalMetrics,
|
||||||
|
};
|
||||||
|
}
|
||||||
75
packages/db/src/engine/index.ts
Normal file
75
packages/db/src/engine/index.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { getPreviousMetric } from '@openpanel/common';
|
||||||
|
|
||||||
|
import type { FinalChart, IChartInput } from '@openpanel/validation';
|
||||||
|
import { getChartPrevStartEndDate } from '../services/chart.service';
|
||||||
|
import {
|
||||||
|
getOrganizationSubscriptionChartEndDate,
|
||||||
|
getSettingsForProject,
|
||||||
|
} from '../services/organization.service';
|
||||||
|
import { compute } from './compute';
|
||||||
|
import { fetch } from './fetch';
|
||||||
|
import { format } from './format';
|
||||||
|
import { normalize } from './normalize';
|
||||||
|
import { plan } from './plan';
|
||||||
|
import type { ConcreteSeries } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chart Engine - Main entry point
|
||||||
|
* Executes the pipeline: normalize -> plan -> fetch -> compute -> format
|
||||||
|
*/
|
||||||
|
export async function executeChart(input: IChartInput): Promise<FinalChart> {
|
||||||
|
// Stage 1: Normalize input
|
||||||
|
const normalized = await normalize(input);
|
||||||
|
|
||||||
|
// Handle subscription end date limit
|
||||||
|
const endDate = await getOrganizationSubscriptionChartEndDate(
|
||||||
|
input.projectId,
|
||||||
|
normalized.endDate,
|
||||||
|
);
|
||||||
|
if (endDate) {
|
||||||
|
normalized.endDate = endDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stage 2: Create execution plan
|
||||||
|
const executionPlan = await plan(normalized);
|
||||||
|
|
||||||
|
// Stage 3: Fetch data for event series (current period)
|
||||||
|
const fetchedSeries = await fetch(executionPlan);
|
||||||
|
|
||||||
|
// Stage 4: Compute formula series
|
||||||
|
const computedSeries = compute(fetchedSeries, executionPlan.definitions);
|
||||||
|
|
||||||
|
// Stage 5: Fetch previous period if requested
|
||||||
|
let previousSeries: ConcreteSeries[] | null = null;
|
||||||
|
if (input.previous) {
|
||||||
|
const currentPeriod = {
|
||||||
|
startDate: normalized.startDate,
|
||||||
|
endDate: normalized.endDate,
|
||||||
|
};
|
||||||
|
const previousPeriod = getChartPrevStartEndDate(currentPeriod);
|
||||||
|
|
||||||
|
const previousPlan = await plan({
|
||||||
|
...normalized,
|
||||||
|
...previousPeriod,
|
||||||
|
});
|
||||||
|
|
||||||
|
const previousFetched = await fetch(previousPlan);
|
||||||
|
previousSeries = compute(previousFetched, previousPlan.definitions);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stage 6: Format final output with previous period data
|
||||||
|
const includeAlphaIds = executionPlan.definitions.length > 1;
|
||||||
|
const response = format(
|
||||||
|
computedSeries,
|
||||||
|
executionPlan.definitions,
|
||||||
|
includeAlphaIds,
|
||||||
|
previousSeries,
|
||||||
|
);
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export as ChartEngine for backward compatibility
|
||||||
|
export const ChartEngine = {
|
||||||
|
execute: executeChart,
|
||||||
|
};
|
||||||
66
packages/db/src/engine/normalize.ts
Normal file
66
packages/db/src/engine/normalize.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { alphabetIds } from '@openpanel/constants';
|
||||||
|
import type {
|
||||||
|
IChartEvent,
|
||||||
|
IChartEventItem,
|
||||||
|
IChartInput,
|
||||||
|
IChartInputWithDates,
|
||||||
|
} from '@openpanel/validation';
|
||||||
|
import { getChartStartEndDate } from '../services/chart.service';
|
||||||
|
import { getSettingsForProject } from '../services/organization.service';
|
||||||
|
import type { SeriesDefinition } from './types';
|
||||||
|
|
||||||
|
export type NormalizedInput = Awaited<ReturnType<typeof normalize>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a chart input into a clean structure with dates and normalized series
|
||||||
|
*/
|
||||||
|
export async function normalize(
|
||||||
|
input: IChartInput,
|
||||||
|
): Promise<IChartInputWithDates & { series: SeriesDefinition[] }> {
|
||||||
|
const { timezone } = await getSettingsForProject(input.projectId);
|
||||||
|
const { startDate, endDate } = getChartStartEndDate(
|
||||||
|
{
|
||||||
|
range: input.range,
|
||||||
|
startDate: input.startDate ?? undefined,
|
||||||
|
endDate: input.endDate ?? undefined,
|
||||||
|
},
|
||||||
|
timezone,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get series from input (handles both 'series' and 'events' fields)
|
||||||
|
// The schema preprocessing should have already converted 'events' to 'series', but handle both for safety
|
||||||
|
const rawSeries = (input as any).series ?? (input as any).events ?? [];
|
||||||
|
|
||||||
|
// Normalize each series item
|
||||||
|
const normalizedSeries: SeriesDefinition[] = rawSeries.map(
|
||||||
|
(item: any, index: number) => {
|
||||||
|
// If item already has type field, it's the new format
|
||||||
|
if (item && typeof item === 'object' && 'type' in item) {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
id: item.id ?? alphabetIds[index] ?? `series-${index}`,
|
||||||
|
} as SeriesDefinition;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Old format without type field - assume it's an event
|
||||||
|
const event = item as Partial<IChartEvent>;
|
||||||
|
return {
|
||||||
|
type: 'event',
|
||||||
|
id: event.id ?? alphabetIds[index] ?? `series-${index}`,
|
||||||
|
name: event.name || 'unknown_event',
|
||||||
|
segment: event.segment ?? 'event',
|
||||||
|
filters: event.filters ?? [],
|
||||||
|
displayName: event.displayName,
|
||||||
|
property: event.property,
|
||||||
|
} as SeriesDefinition;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...input,
|
||||||
|
series: normalizedSeries,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
50
packages/db/src/engine/plan.ts
Normal file
50
packages/db/src/engine/plan.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { slug } from '@openpanel/common';
|
||||||
|
import { alphabetIds } from '@openpanel/constants';
|
||||||
|
import type { IChartEventItem } from '@openpanel/validation';
|
||||||
|
import { getSettingsForProject } from '../services/organization.service';
|
||||||
|
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> {
|
||||||
|
const { timezone } = await getSettingsForProject(normalized.projectId);
|
||||||
|
|
||||||
|
const concreteSeries: ConcreteSeries[] = [];
|
||||||
|
|
||||||
|
// Create concrete series placeholders for each definition
|
||||||
|
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
|
||||||
|
const concrete: ConcreteSeries = {
|
||||||
|
id: `${slug(event.name)}-${event.id ?? index}`,
|
||||||
|
definitionId: event.id ?? alphabetIds[index] ?? `series-${index}`,
|
||||||
|
definitionIndex: index,
|
||||||
|
name: [event.displayName || event.name],
|
||||||
|
context: {
|
||||||
|
event: event.name,
|
||||||
|
filters: [...event.filters],
|
||||||
|
},
|
||||||
|
data: [], // Will be populated by fetch stage
|
||||||
|
definition,
|
||||||
|
};
|
||||||
|
concreteSeries.push(concrete);
|
||||||
|
} else {
|
||||||
|
// For formulas, we'll create placeholders during compute stage
|
||||||
|
// Formulas depend on event series, so we skip them here
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
concreteSeries,
|
||||||
|
definitions: normalized.series,
|
||||||
|
input: normalized,
|
||||||
|
timezone,
|
||||||
|
};
|
||||||
|
}
|
||||||
85
packages/db/src/engine/types.ts
Normal file
85
packages/db/src/engine/types.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import type {
|
||||||
|
IChartBreakdown,
|
||||||
|
IChartEvent,
|
||||||
|
IChartEventFilter,
|
||||||
|
IChartEventItem,
|
||||||
|
IChartFormula,
|
||||||
|
IChartInput,
|
||||||
|
IChartInputWithDates,
|
||||||
|
} from '@openpanel/validation';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Series Definition - The input representation of what the user wants
|
||||||
|
* This is what comes from the frontend (events or formulas)
|
||||||
|
*/
|
||||||
|
export type SeriesDefinition = IChartEventItem;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Concrete Series - A resolved series that will be displayed as a line/bar on the chart
|
||||||
|
* When breakdowns exist, one SeriesDefinition can expand into multiple ConcreteSeries
|
||||||
|
*/
|
||||||
|
export type ConcreteSeries = {
|
||||||
|
id: string;
|
||||||
|
definitionId: string; // ID of the SeriesDefinition this came from
|
||||||
|
definitionIndex: number; // Index in the original series array (for A, B, C references)
|
||||||
|
name: string[]; // Display name parts: ["Session Start", "Chrome"] or ["Formula 1"]
|
||||||
|
|
||||||
|
// Context for Drill-down / Profiles
|
||||||
|
// This contains everything needed to query 'who are these users?'
|
||||||
|
context: {
|
||||||
|
event?: string; // Event name (if this is an event series)
|
||||||
|
filters: IChartEventFilter[]; // All filters including breakdown value
|
||||||
|
breakdownValue?: string; // The breakdown value for this concrete series (deprecated, use breakdowns instead)
|
||||||
|
breakdowns?: Record<string, string>; // Breakdown keys and values: { country: 'SE', path: '/ewoqmepwq' }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Data points for this series
|
||||||
|
data: Array<{
|
||||||
|
date: string;
|
||||||
|
count: number;
|
||||||
|
total_count?: number;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
// The original definition (event or formula)
|
||||||
|
definition: SeriesDefinition;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plan - The execution plan after normalization and expansion
|
||||||
|
*/
|
||||||
|
export type Plan = {
|
||||||
|
concreteSeries: ConcreteSeries[];
|
||||||
|
definitions: SeriesDefinition[];
|
||||||
|
input: IChartInputWithDates;
|
||||||
|
timezone: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chart Response - The final output format
|
||||||
|
*/
|
||||||
|
export type ChartResponse = {
|
||||||
|
series: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string[];
|
||||||
|
data: Array<{
|
||||||
|
date: string;
|
||||||
|
value: number;
|
||||||
|
previous?: number;
|
||||||
|
}>;
|
||||||
|
summary: {
|
||||||
|
total: number;
|
||||||
|
average: number;
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
count?: number;
|
||||||
|
};
|
||||||
|
context?: ConcreteSeries['context']; // Include context for drill-down
|
||||||
|
}>;
|
||||||
|
summary: {
|
||||||
|
total: number;
|
||||||
|
average: number;
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
@@ -155,7 +155,8 @@ export function getChartSql({
|
|||||||
}
|
}
|
||||||
|
|
||||||
breakdowns.forEach((breakdown, index) => {
|
breakdowns.forEach((breakdown, index) => {
|
||||||
const key = `label_${index}`;
|
// Breakdowns start at label_1 (label_0 is reserved for event name)
|
||||||
|
const key = `label_${index + 1}`;
|
||||||
sb.select[key] = `${getSelectPropertyKey(breakdown.name)} as ${key}`;
|
sb.select[key] = `${getSelectPropertyKey(breakdown.name)} as ${key}`;
|
||||||
sb.groupBy[key] = `${key}`;
|
sb.groupBy[key] = `${key}`;
|
||||||
});
|
});
|
||||||
@@ -175,8 +176,8 @@ export function getChartSql({
|
|||||||
|
|
||||||
if (event.segment === 'property_sum' && event.property) {
|
if (event.segment === 'property_sum' && event.property) {
|
||||||
if (event.property === 'revenue') {
|
if (event.property === 'revenue') {
|
||||||
sb.select.count = `sum(revenue) as count`;
|
sb.select.count = 'sum(revenue) as count';
|
||||||
sb.where.property = `revenue > 0`;
|
sb.where.property = 'revenue > 0';
|
||||||
} else {
|
} else {
|
||||||
sb.select.count = `sum(toFloat64(${getSelectPropertyKey(event.property)})) as count`;
|
sb.select.count = `sum(toFloat64(${getSelectPropertyKey(event.property)})) as count`;
|
||||||
sb.where.property = `${getSelectPropertyKey(event.property)} IS NOT NULL AND notEmpty(${getSelectPropertyKey(event.property)})`;
|
sb.where.property = `${getSelectPropertyKey(event.property)} IS NOT NULL AND notEmpty(${getSelectPropertyKey(event.property)})`;
|
||||||
@@ -185,8 +186,8 @@ export function getChartSql({
|
|||||||
|
|
||||||
if (event.segment === 'property_average' && event.property) {
|
if (event.segment === 'property_average' && event.property) {
|
||||||
if (event.property === 'revenue') {
|
if (event.property === 'revenue') {
|
||||||
sb.select.count = `avg(revenue) as count`;
|
sb.select.count = 'avg(revenue) as count';
|
||||||
sb.where.property = `revenue > 0`;
|
sb.where.property = 'revenue > 0';
|
||||||
} else {
|
} else {
|
||||||
sb.select.count = `avg(toFloat64(${getSelectPropertyKey(event.property)})) as count`;
|
sb.select.count = `avg(toFloat64(${getSelectPropertyKey(event.property)})) as count`;
|
||||||
sb.where.property = `${getSelectPropertyKey(event.property)} IS NOT NULL AND notEmpty(${getSelectPropertyKey(event.property)})`;
|
sb.where.property = `${getSelectPropertyKey(event.property)} IS NOT NULL AND notEmpty(${getSelectPropertyKey(event.property)})`;
|
||||||
@@ -195,8 +196,8 @@ export function getChartSql({
|
|||||||
|
|
||||||
if (event.segment === 'property_max' && event.property) {
|
if (event.segment === 'property_max' && event.property) {
|
||||||
if (event.property === 'revenue') {
|
if (event.property === 'revenue') {
|
||||||
sb.select.count = `max(revenue) as count`;
|
sb.select.count = 'max(revenue) as count';
|
||||||
sb.where.property = `revenue > 0`;
|
sb.where.property = 'revenue > 0';
|
||||||
} else {
|
} else {
|
||||||
sb.select.count = `max(toFloat64(${getSelectPropertyKey(event.property)})) as count`;
|
sb.select.count = `max(toFloat64(${getSelectPropertyKey(event.property)})) as count`;
|
||||||
sb.where.property = `${getSelectPropertyKey(event.property)} IS NOT NULL AND notEmpty(${getSelectPropertyKey(event.property)})`;
|
sb.where.property = `${getSelectPropertyKey(event.property)} IS NOT NULL AND notEmpty(${getSelectPropertyKey(event.property)})`;
|
||||||
@@ -205,8 +206,8 @@ export function getChartSql({
|
|||||||
|
|
||||||
if (event.segment === 'property_min' && event.property) {
|
if (event.segment === 'property_min' && event.property) {
|
||||||
if (event.property === 'revenue') {
|
if (event.property === 'revenue') {
|
||||||
sb.select.count = `min(revenue) as count`;
|
sb.select.count = 'min(revenue) as count';
|
||||||
sb.where.property = `revenue > 0`;
|
sb.where.property = 'revenue > 0';
|
||||||
} else {
|
} else {
|
||||||
sb.select.count = `min(toFloat64(${getSelectPropertyKey(event.property)})) as count`;
|
sb.select.count = `min(toFloat64(${getSelectPropertyKey(event.property)})) as count`;
|
||||||
sb.where.property = `${getSelectPropertyKey(event.property)} IS NOT NULL AND notEmpty(${getSelectPropertyKey(event.property)})`;
|
sb.where.property = `${getSelectPropertyKey(event.property)} IS NOT NULL AND notEmpty(${getSelectPropertyKey(event.property)})`;
|
||||||
@@ -230,14 +231,58 @@ export function getChartSql({
|
|||||||
return sql;
|
return sql;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add total unique count for user segment using a scalar subquery
|
// Build total_count calculation that accounts for breakdowns
|
||||||
if (event.segment === 'user') {
|
// When breakdowns exist, we need to calculate total_count per breakdown group
|
||||||
const totalUniqueSubquery = `(
|
if (breakdowns.length > 0) {
|
||||||
SELECT ${sb.select.count}
|
// Create a subquery that calculates total_count per breakdown group (without date grouping)
|
||||||
|
// Then reference it in the main query via JOIN
|
||||||
|
const breakdownSelects = breakdowns
|
||||||
|
.map((breakdown, index) => {
|
||||||
|
const key = `label_${index + 1}`;
|
||||||
|
const breakdownExpr = getSelectPropertyKey(breakdown.name);
|
||||||
|
return `${breakdownExpr} as ${key}`;
|
||||||
|
})
|
||||||
|
.join(', ');
|
||||||
|
|
||||||
|
// GROUP BY needs to use the actual expressions, not aliases
|
||||||
|
const breakdownGroupByExprs = breakdowns
|
||||||
|
.map((breakdown) => getSelectPropertyKey(breakdown.name))
|
||||||
|
.join(', ');
|
||||||
|
|
||||||
|
// Build the total_count subquery grouped only by breakdowns (no date)
|
||||||
|
// Extract the count expression without the alias (remove "as count")
|
||||||
|
const countExpression = sb.select.count.replace(/\s+as\s+count$/i, '');
|
||||||
|
const totalCountSubquery = `(
|
||||||
|
SELECT
|
||||||
|
${breakdownSelects},
|
||||||
|
${countExpression} as total_count
|
||||||
FROM ${sb.from}
|
FROM ${sb.from}
|
||||||
${getJoins()}
|
${getJoins()}
|
||||||
${getWhere()}
|
${getWhere()}
|
||||||
)`;
|
GROUP BY ${breakdownGroupByExprs}
|
||||||
|
) as total_counts`;
|
||||||
|
|
||||||
|
// Join the total_counts subquery to get total_count per breakdown
|
||||||
|
// Match on the breakdown column values
|
||||||
|
const joinConditions = breakdowns
|
||||||
|
.map((_, index) => {
|
||||||
|
const outerKey = `label_${index + 1}`;
|
||||||
|
return `${outerKey} = total_counts.label_${index + 1}`;
|
||||||
|
})
|
||||||
|
.join(' AND ');
|
||||||
|
|
||||||
|
sb.joins.total_counts = `LEFT JOIN ${totalCountSubquery} ON ${joinConditions}`;
|
||||||
|
// Use any() aggregate since total_count is the same for all rows in a breakdown group
|
||||||
|
sb.select.total_unique_count =
|
||||||
|
'any(total_counts.total_count) as total_count';
|
||||||
|
} else {
|
||||||
|
// No breakdowns - use a simple subquery for total count
|
||||||
|
const totalUniqueSubquery = `(
|
||||||
|
SELECT ${sb.select.count}
|
||||||
|
FROM ${sb.from}
|
||||||
|
${getJoins()}
|
||||||
|
${getWhere()}
|
||||||
|
)`;
|
||||||
sb.select.total_unique_count = `${totalUniqueSubquery} as total_count`;
|
sb.select.total_unique_count = `${totalUniqueSubquery} as total_count`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -509,12 +554,11 @@ export function getChartStartEndDate(
|
|||||||
}: Pick<IChartInput, 'endDate' | 'startDate' | 'range'>,
|
}: Pick<IChartInput, 'endDate' | 'startDate' | 'range'>,
|
||||||
timezone: string,
|
timezone: string,
|
||||||
) {
|
) {
|
||||||
const ranges = getDatesFromRange(range, timezone);
|
|
||||||
|
|
||||||
if (startDate && endDate) {
|
if (startDate && endDate) {
|
||||||
return { startDate: startDate, endDate: endDate };
|
return { startDate: startDate, endDate: endDate };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ranges = getDatesFromRange(range, timezone);
|
||||||
if (!startDate && endDate) {
|
if (!startDate && endDate) {
|
||||||
return { startDate: ranges.startDate, endDate: endDate };
|
return { startDate: ranges.startDate, endDate: endDate };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NOT_SET_VALUE } from '@openpanel/constants';
|
import { NOT_SET_VALUE } from '@openpanel/constants';
|
||||||
import type { IChartInput } from '@openpanel/validation';
|
import type { IChartEvent, IChartInput } from '@openpanel/validation';
|
||||||
import { omit } from 'ramda';
|
import { omit } from 'ramda';
|
||||||
import { TABLE_NAMES, ch } from '../clickhouse/client';
|
import { TABLE_NAMES, ch } from '../clickhouse/client';
|
||||||
import { clix } from '../clickhouse/query-builder';
|
import { clix } from '../clickhouse/query-builder';
|
||||||
@@ -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) {}
|
||||||
@@ -17,8 +18,9 @@ export class ConversionService {
|
|||||||
endDate,
|
endDate,
|
||||||
funnelGroup,
|
funnelGroup,
|
||||||
funnelWindow = 24,
|
funnelWindow = 24,
|
||||||
events,
|
series,
|
||||||
breakdowns = [],
|
breakdowns = [],
|
||||||
|
limit,
|
||||||
interval,
|
interval,
|
||||||
timezone,
|
timezone,
|
||||||
}: Omit<IChartInput, 'range' | 'previous' | 'metric' | 'chartType'> & {
|
}: Omit<IChartInput, 'range' | 'previous' | 'metric' | 'chartType'> & {
|
||||||
@@ -30,6 +32,8 @@ export class ConversionService {
|
|||||||
);
|
);
|
||||||
const breakdownGroupBy = breakdowns.map((b, index) => `b_${index}`);
|
const breakdownGroupBy = breakdowns.map((b, index) => `b_${index}`);
|
||||||
|
|
||||||
|
const events = onlyReportEvents(series);
|
||||||
|
|
||||||
if (events.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');
|
||||||
}
|
}
|
||||||
@@ -111,18 +115,20 @@ export class ConversionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const results = await query.execute();
|
const results = await query.execute();
|
||||||
return this.toSeries(results, breakdowns).map((serie, serieIndex) => {
|
return this.toSeries(results, breakdowns, limit).map(
|
||||||
return {
|
(serie, serieIndex) => {
|
||||||
...serie,
|
return {
|
||||||
data: serie.data.map((d, index) => ({
|
...serie,
|
||||||
...d,
|
data: serie.data.map((d, index) => ({
|
||||||
timestamp: new Date(d.date).getTime(),
|
...d,
|
||||||
serieIndex,
|
timestamp: new Date(d.date).getTime(),
|
||||||
index,
|
serieIndex,
|
||||||
serie: omit(['data'], serie),
|
index,
|
||||||
})),
|
serie: omit(['data'], serie),
|
||||||
};
|
})),
|
||||||
});
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private toSeries(
|
private toSeries(
|
||||||
@@ -134,6 +140,7 @@ export class ConversionService {
|
|||||||
[key: string]: string | number;
|
[key: string]: string | number;
|
||||||
}[],
|
}[],
|
||||||
breakdowns: { name: string }[] = [],
|
breakdowns: { name: string }[] = [],
|
||||||
|
limit: number | undefined = undefined,
|
||||||
) {
|
) {
|
||||||
if (!breakdowns.length) {
|
if (!breakdowns.length) {
|
||||||
return [
|
return [
|
||||||
@@ -153,6 +160,10 @@ export class ConversionService {
|
|||||||
// Group by breakdown values
|
// Group by breakdown values
|
||||||
const series = data.reduce(
|
const series = data.reduce(
|
||||||
(acc, d) => {
|
(acc, d) => {
|
||||||
|
if (limit && Object.keys(acc).length >= limit) {
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
|
||||||
const key =
|
const key =
|
||||||
breakdowns.map((b, index) => d[`b_${index}`]).join('|') ||
|
breakdowns.map((b, index) => d[`b_${index}`]).join('|') ||
|
||||||
NOT_SET_VALUE;
|
NOT_SET_VALUE;
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { ifNaN } from '@openpanel/common';
|
import { ifNaN } from '@openpanel/common';
|
||||||
import type { IChartEvent, IChartInput } from '@openpanel/validation';
|
import type {
|
||||||
|
IChartEvent,
|
||||||
|
IChartEventItem,
|
||||||
|
IChartInput,
|
||||||
|
} from '@openpanel/validation';
|
||||||
import { last, reverse, uniq } from 'ramda';
|
import { last, reverse, uniq } from 'ramda';
|
||||||
import sqlstring from 'sqlstring';
|
import sqlstring from 'sqlstring';
|
||||||
import { ch } from '../clickhouse/client';
|
import { ch } from '../clickhouse/client';
|
||||||
@@ -10,17 +14,18 @@ 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) {}
|
||||||
|
|
||||||
private getFunnelGroup(group?: string) {
|
getFunnelGroup(group?: string): [string, string] {
|
||||||
return group === 'profile_id'
|
return group === 'profile_id'
|
||||||
? [`COALESCE(nullIf(s.pid, ''), profile_id)`, 'profile_id']
|
? [`COALESCE(nullIf(s.pid, ''), profile_id)`, 'profile_id']
|
||||||
: ['session_id', 'session_id'];
|
: ['session_id', 'session_id'];
|
||||||
}
|
}
|
||||||
|
|
||||||
private getFunnelConditions(events: IChartEvent[]) {
|
getFunnelConditions(events: IChartEvent[] = []): string[] {
|
||||||
return events.map((event) => {
|
return events.map((event) => {
|
||||||
const { sb, getWhere } = createSqlBuilder();
|
const { sb, getWhere } = createSqlBuilder();
|
||||||
sb.where = getEventFiltersWhereClause(event.filters);
|
sb.where = getEventFiltersWhereClause(event.filters);
|
||||||
@@ -29,6 +34,70 @@ export class FunnelService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
buildFunnelCte({
|
||||||
|
projectId,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
eventSeries,
|
||||||
|
funnelWindowMilliseconds,
|
||||||
|
group,
|
||||||
|
timezone,
|
||||||
|
additionalSelects = [],
|
||||||
|
additionalGroupBy = [],
|
||||||
|
}: {
|
||||||
|
projectId: string;
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
eventSeries: IChartEvent[];
|
||||||
|
funnelWindowMilliseconds: number;
|
||||||
|
group: [string, string];
|
||||||
|
timezone: string;
|
||||||
|
additionalSelects?: string[];
|
||||||
|
additionalGroupBy?: string[];
|
||||||
|
}) {
|
||||||
|
const funnels = this.getFunnelConditions(eventSeries);
|
||||||
|
|
||||||
|
return clix(this.client, timezone)
|
||||||
|
.select([
|
||||||
|
`${group[0]} AS ${group[1]}`,
|
||||||
|
...additionalSelects,
|
||||||
|
`windowFunnel(${funnelWindowMilliseconds}, 'strict_increase')(toUInt64(toUnixTimestamp64Milli(created_at)), ${funnels.join(', ')}) AS level`,
|
||||||
|
])
|
||||||
|
.from(TABLE_NAMES.events, false)
|
||||||
|
.where('project_id', '=', projectId)
|
||||||
|
.where('created_at', 'BETWEEN', [
|
||||||
|
clix.datetime(startDate, 'toDateTime'),
|
||||||
|
clix.datetime(endDate, 'toDateTime'),
|
||||||
|
])
|
||||||
|
.where(
|
||||||
|
'name',
|
||||||
|
'IN',
|
||||||
|
eventSeries.map((e) => e.name),
|
||||||
|
)
|
||||||
|
.groupBy([group[1], ...additionalGroupBy]);
|
||||||
|
}
|
||||||
|
|
||||||
|
buildSessionsCte({
|
||||||
|
projectId,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
timezone,
|
||||||
|
}: {
|
||||||
|
projectId: string;
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
timezone: string;
|
||||||
|
}) {
|
||||||
|
return clix(this.client, timezone)
|
||||||
|
.select(['profile_id as pid', 'id as sid'])
|
||||||
|
.from(TABLE_NAMES.sessions)
|
||||||
|
.where('project_id', '=', projectId)
|
||||||
|
.where('created_at', 'BETWEEN', [
|
||||||
|
clix.datetime(startDate, 'toDateTime'),
|
||||||
|
clix.datetime(endDate, 'toDateTime'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
private fillFunnel(
|
private fillFunnel(
|
||||||
funnel: { level: number; count: number }[],
|
funnel: { level: number; count: number }[],
|
||||||
steps: number,
|
steps: number,
|
||||||
@@ -57,6 +126,7 @@ export class FunnelService {
|
|||||||
toSeries(
|
toSeries(
|
||||||
funnel: { level: number; count: number; [key: string]: any }[],
|
funnel: { level: number; count: number; [key: string]: any }[],
|
||||||
breakdowns: { name: string }[] = [],
|
breakdowns: { name: string }[] = [],
|
||||||
|
limit: number | undefined = undefined,
|
||||||
) {
|
) {
|
||||||
if (!breakdowns.length) {
|
if (!breakdowns.length) {
|
||||||
return [
|
return [
|
||||||
@@ -72,6 +142,10 @@ export class FunnelService {
|
|||||||
// Group by breakdown values
|
// Group by breakdown values
|
||||||
const series = funnel.reduce(
|
const series = funnel.reduce(
|
||||||
(acc, f) => {
|
(acc, f) => {
|
||||||
|
if (limit && Object.keys(acc).length >= limit) {
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
|
||||||
const key = breakdowns.map((b, index) => f[`b_${index}`]).join('|');
|
const key = breakdowns.map((b, index) => f[`b_${index}`]).join('|');
|
||||||
if (!acc[key]) {
|
if (!acc[key]) {
|
||||||
acc[key] = [];
|
acc[key] = [];
|
||||||
@@ -110,51 +184,49 @@ export class FunnelService {
|
|||||||
projectId,
|
projectId,
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
events,
|
series,
|
||||||
funnelWindow = 24,
|
funnelWindow = 24,
|
||||||
funnelGroup,
|
funnelGroup,
|
||||||
breakdowns = [],
|
breakdowns = [],
|
||||||
|
limit,
|
||||||
timezone = 'UTC',
|
timezone = 'UTC',
|
||||||
}: IChartInput & { timezone: string }) {
|
}: IChartInput & { timezone: string; events?: IChartEvent[] }) {
|
||||||
if (!startDate || !endDate) {
|
if (!startDate || !endDate) {
|
||||||
throw new Error('startDate and endDate are required');
|
throw new Error('startDate and endDate are required');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (events.length === 0) {
|
const eventSeries = onlyReportEvents(series);
|
||||||
|
|
||||||
|
if (eventSeries.length === 0) {
|
||||||
throw new Error('events are required');
|
throw new Error('events are required');
|
||||||
}
|
}
|
||||||
|
|
||||||
const funnelWindowSeconds = funnelWindow * 3600;
|
const funnelWindowSeconds = funnelWindow * 3600;
|
||||||
const funnelWindowMilliseconds = funnelWindowSeconds * 1000;
|
const funnelWindowMilliseconds = funnelWindowSeconds * 1000;
|
||||||
const group = this.getFunnelGroup(funnelGroup);
|
const group = this.getFunnelGroup(funnelGroup);
|
||||||
const funnels = this.getFunnelConditions(events);
|
const profileFilters = this.getProfileFilters(eventSeries);
|
||||||
const profileFilters = this.getProfileFilters(events);
|
|
||||||
const anyFilterOnProfile = profileFilters.length > 0;
|
const anyFilterOnProfile = profileFilters.length > 0;
|
||||||
const anyBreakdownOnProfile = breakdowns.some((b) =>
|
const anyBreakdownOnProfile = breakdowns.some((b) =>
|
||||||
b.name.startsWith('profile.'),
|
b.name.startsWith('profile.'),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create the funnel CTE
|
// Create the funnel CTE
|
||||||
const funnelCte = clix(this.client, timezone)
|
const breakdownSelects = breakdowns.map(
|
||||||
.select([
|
(b, index) => `${getSelectPropertyKey(b.name)} as b_${index}`,
|
||||||
`${group[0]} AS ${group[1]}`,
|
);
|
||||||
...breakdowns.map(
|
const breakdownGroupBy = breakdowns.map((b, index) => `b_${index}`);
|
||||||
(b, index) => `${getSelectPropertyKey(b.name)} as b_${index}`,
|
|
||||||
),
|
const funnelCte = this.buildFunnelCte({
|
||||||
`windowFunnel(${funnelWindowMilliseconds}, 'strict_increase')(toUInt64(toUnixTimestamp64Milli(created_at)), ${funnels.join(', ')}) AS level`,
|
projectId,
|
||||||
])
|
startDate,
|
||||||
.from(TABLE_NAMES.events, false)
|
endDate,
|
||||||
.where('project_id', '=', projectId)
|
eventSeries,
|
||||||
.where('created_at', 'BETWEEN', [
|
funnelWindowMilliseconds,
|
||||||
clix.datetime(startDate, 'toDateTime'),
|
group,
|
||||||
clix.datetime(endDate, 'toDateTime'),
|
timezone,
|
||||||
])
|
additionalSelects: breakdownSelects,
|
||||||
.where(
|
additionalGroupBy: breakdownGroupBy,
|
||||||
'name',
|
});
|
||||||
'IN',
|
|
||||||
events.map((e) => e.name),
|
|
||||||
)
|
|
||||||
.groupBy([group[1], ...breakdowns.map((b, index) => `b_${index}`)]);
|
|
||||||
|
|
||||||
if (anyFilterOnProfile || anyBreakdownOnProfile) {
|
if (anyFilterOnProfile || anyBreakdownOnProfile) {
|
||||||
funnelCte.leftJoin(
|
funnelCte.leftJoin(
|
||||||
@@ -167,15 +239,12 @@ export class FunnelService {
|
|||||||
// Create the sessions CTE if needed
|
// Create the sessions CTE if needed
|
||||||
const sessionsCte =
|
const sessionsCte =
|
||||||
group[0] !== 'session_id'
|
group[0] !== 'session_id'
|
||||||
? clix(this.client, timezone)
|
? this.buildSessionsCte({
|
||||||
// Important to have unique field names to avoid ambiguity in the main query
|
projectId,
|
||||||
.select(['profile_id as pid', 'id as sid'])
|
startDate,
|
||||||
.from(TABLE_NAMES.sessions)
|
endDate,
|
||||||
.where('project_id', '=', projectId)
|
timezone,
|
||||||
.where('created_at', 'BETWEEN', [
|
})
|
||||||
clix.datetime(startDate, 'toDateTime'),
|
|
||||||
clix.datetime(endDate, 'toDateTime'),
|
|
||||||
])
|
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
// Base funnel query with CTEs
|
// Base funnel query with CTEs
|
||||||
@@ -204,11 +273,11 @@ export class FunnelService {
|
|||||||
.orderBy('level', 'DESC');
|
.orderBy('level', 'DESC');
|
||||||
|
|
||||||
const funnelData = await funnelQuery.execute();
|
const funnelData = await funnelQuery.execute();
|
||||||
const funnelSeries = this.toSeries(funnelData, breakdowns);
|
const funnelSeries = this.toSeries(funnelData, breakdowns, limit);
|
||||||
|
|
||||||
return funnelSeries
|
return funnelSeries
|
||||||
.map((data) => {
|
.map((data) => {
|
||||||
const maxLevel = events.length;
|
const maxLevel = eventSeries.length;
|
||||||
const filledFunnelRes = this.fillFunnel(
|
const filledFunnelRes = this.fillFunnel(
|
||||||
data.map((d) => ({ level: d.level, count: d.count })),
|
data.map((d) => ({ level: d.level, count: d.count })),
|
||||||
maxLevel,
|
maxLevel,
|
||||||
@@ -220,7 +289,7 @@ export class FunnelService {
|
|||||||
(acc, item, index, list) => {
|
(acc, item, index, list) => {
|
||||||
const prev = list[index - 1] ?? { count: totalSessions };
|
const prev = list[index - 1] ?? { count: totalSessions };
|
||||||
const next = list[index + 1];
|
const next = list[index + 1];
|
||||||
const event = events[item.level - 1]!;
|
const event = eventSeries[item.level - 1]!;
|
||||||
return [
|
return [
|
||||||
...acc,
|
...acc,
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,19 +5,25 @@ import {
|
|||||||
} from '@openpanel/constants';
|
} from '@openpanel/constants';
|
||||||
import type {
|
import type {
|
||||||
IChartBreakdown,
|
IChartBreakdown,
|
||||||
IChartEvent,
|
|
||||||
IChartEventFilter,
|
IChartEventFilter,
|
||||||
|
IChartEventItem,
|
||||||
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,
|
||||||
@@ -31,17 +37,29 @@ export function transformFilter(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function transformReportEvent(
|
export function transformReportEventItem(
|
||||||
event: Partial<IChartEvent>,
|
item: IChartEventItem,
|
||||||
index: number,
|
index: number,
|
||||||
): IChartEvent {
|
): IChartEventItem {
|
||||||
|
if (item.type === 'formula') {
|
||||||
|
// Transform formula
|
||||||
|
return {
|
||||||
|
type: 'formula',
|
||||||
|
id: item.id ?? alphabetIds[index]!,
|
||||||
|
formula: item.formula || '',
|
||||||
|
displayName: item.displayName,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transform event with type field
|
||||||
return {
|
return {
|
||||||
segment: event.segment ?? 'event',
|
type: 'event',
|
||||||
filters: (event.filters ?? []).map(transformFilter),
|
segment: item.segment ?? 'event',
|
||||||
id: event.id ?? alphabetIds[index]!,
|
filters: (item.filters ?? []).map(transformFilter),
|
||||||
name: event.name || 'unknown_event',
|
id: item.id ?? alphabetIds[index]!,
|
||||||
displayName: event.displayName,
|
name: item.name || 'unknown_event',
|
||||||
property: event.property,
|
displayName: item.displayName,
|
||||||
|
property: item.property,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,7 +69,8 @@ export function transformReport(
|
|||||||
return {
|
return {
|
||||||
id: report.id,
|
id: report.id,
|
||||||
projectId: report.projectId,
|
projectId: report.projectId,
|
||||||
events: (report.events as IChartEvent[]).map(transformReportEvent),
|
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,
|
||||||
|
|||||||
@@ -1,544 +0,0 @@
|
|||||||
import type { IChartEvent, IChartInput } from '@openpanel/validation';
|
|
||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import { withFormula } from './chart.helpers';
|
|
||||||
|
|
||||||
// Helper to create a test event
|
|
||||||
function createEvent(
|
|
||||||
id: string,
|
|
||||||
name: string,
|
|
||||||
displayName?: string,
|
|
||||||
): IChartEvent {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
name,
|
|
||||||
displayName: displayName ?? '',
|
|
||||||
segment: 'event',
|
|
||||||
filters: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const createChartInput = (
|
|
||||||
rest: Pick<IChartInput, 'events' | 'formula'>,
|
|
||||||
): IChartInput => {
|
|
||||||
return {
|
|
||||||
metric: 'sum',
|
|
||||||
chartType: 'linear',
|
|
||||||
interval: 'day',
|
|
||||||
breakdowns: [],
|
|
||||||
projectId: '1',
|
|
||||||
startDate: '2025-01-01',
|
|
||||||
endDate: '2025-01-01',
|
|
||||||
range: '30d',
|
|
||||||
previous: false,
|
|
||||||
formula: '',
|
|
||||||
...rest,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper to create a test series
|
|
||||||
function createSeries(
|
|
||||||
name: string[],
|
|
||||||
event: IChartEvent,
|
|
||||||
data: Array<{ date: string; count: number }>,
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
name,
|
|
||||||
event,
|
|
||||||
data: data.map((d) => ({ ...d, total_count: d.count })),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('withFormula', () => {
|
|
||||||
describe('edge cases', () => {
|
|
||||||
it('should return series unchanged when formula is empty', () => {
|
|
||||||
const events = [createEvent('evt1', 'event1')];
|
|
||||||
const series = [
|
|
||||||
createSeries(['event1'], events[0]!, [
|
|
||||||
{ date: '2025-01-01', count: 10 },
|
|
||||||
]),
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = withFormula(
|
|
||||||
createChartInput({ formula: '', events }),
|
|
||||||
series,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result).toEqual(series);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return series unchanged when series is empty', () => {
|
|
||||||
const events = [createEvent('evt1', 'event1')];
|
|
||||||
const result = withFormula(
|
|
||||||
createChartInput({ formula: 'A*100', events }),
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return series unchanged when series has no data', () => {
|
|
||||||
const events = [createEvent('evt1', 'event1')];
|
|
||||||
const series = [{ name: ['event1'], event: events[0]!, data: [] }];
|
|
||||||
|
|
||||||
const result = withFormula(
|
|
||||||
createChartInput({ formula: 'A*100', events }),
|
|
||||||
series,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result).toEqual(series);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('single event, no breakdown', () => {
|
|
||||||
it('should apply simple multiplication formula', () => {
|
|
||||||
const events = [createEvent('evt1', 'event1')];
|
|
||||||
const series = [
|
|
||||||
createSeries(['event1'], events[0]!, [
|
|
||||||
{ date: '2025-01-01', count: 10 },
|
|
||||||
{ date: '2025-01-02', count: 20 },
|
|
||||||
]),
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = withFormula(
|
|
||||||
createChartInput({ formula: 'A*100', events }),
|
|
||||||
series,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result).toHaveLength(1);
|
|
||||||
expect(result[0]?.data).toEqual([
|
|
||||||
{ date: '2025-01-01', count: 1000, total_count: 10 },
|
|
||||||
{ date: '2025-01-02', count: 2000, total_count: 20 },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should apply addition formula', () => {
|
|
||||||
const events = [createEvent('evt1', 'event1')];
|
|
||||||
const series = [
|
|
||||||
createSeries(['event1'], events[0]!, [
|
|
||||||
{ date: '2025-01-01', count: 5 },
|
|
||||||
]),
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = withFormula(
|
|
||||||
createChartInput({ formula: 'A+10', events }),
|
|
||||||
series,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result[0]?.data[0]?.count).toBe(15);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle division formula', () => {
|
|
||||||
const events = [createEvent('evt1', 'event1')];
|
|
||||||
const series = [
|
|
||||||
createSeries(['event1'], events[0]!, [
|
|
||||||
{ date: '2025-01-01', count: 100 },
|
|
||||||
]),
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = withFormula(
|
|
||||||
createChartInput({ formula: 'A/10', events }),
|
|
||||||
series,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result[0]?.data[0]?.count).toBe(10);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle NaN and Infinity by returning 0', () => {
|
|
||||||
const events = [createEvent('evt1', 'event1')];
|
|
||||||
const series = [
|
|
||||||
createSeries(['event1'], events[0]!, [
|
|
||||||
{ date: '2025-01-01', count: 0 },
|
|
||||||
]),
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = withFormula(
|
|
||||||
createChartInput({ formula: 'A/0', events }),
|
|
||||||
series,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result[0]?.data[0]?.count).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('single event, with breakdown', () => {
|
|
||||||
it('should apply formula to each breakdown group', () => {
|
|
||||||
const events = [createEvent('evt1', 'screen_view')];
|
|
||||||
const series = [
|
|
||||||
createSeries(['iOS'], events[0]!, [{ date: '2025-01-01', count: 10 }]),
|
|
||||||
createSeries(['Android'], events[0]!, [
|
|
||||||
{ date: '2025-01-01', count: 20 },
|
|
||||||
]),
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = withFormula(
|
|
||||||
createChartInput({ formula: 'A*100', events }),
|
|
||||||
series,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result).toHaveLength(2);
|
|
||||||
expect(result[0]?.name).toEqual(['iOS']);
|
|
||||||
expect(result[0]?.data[0]?.count).toBe(1000);
|
|
||||||
expect(result[1]?.name).toEqual(['Android']);
|
|
||||||
expect(result[1]?.data[0]?.count).toBe(2000);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle multiple breakdown values', () => {
|
|
||||||
const events = [createEvent('evt1', 'screen_view')];
|
|
||||||
const series = [
|
|
||||||
createSeries(['iOS', 'US'], events[0]!, [
|
|
||||||
{ date: '2025-01-01', count: 10 },
|
|
||||||
]),
|
|
||||||
createSeries(['Android', 'US'], events[0]!, [
|
|
||||||
{ date: '2025-01-01', count: 20 },
|
|
||||||
]),
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = withFormula(
|
|
||||||
createChartInput({ formula: 'A*2', events }),
|
|
||||||
series,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result).toHaveLength(2);
|
|
||||||
expect(result[0]?.name).toEqual(['iOS', 'US']);
|
|
||||||
expect(result[0]?.data[0]?.count).toBe(20);
|
|
||||||
expect(result[1]?.name).toEqual(['Android', 'US']);
|
|
||||||
expect(result[1]?.data[0]?.count).toBe(40);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('multiple events, no breakdown', () => {
|
|
||||||
it('should combine two events with division formula', () => {
|
|
||||||
const events = [
|
|
||||||
createEvent('evt1', 'screen_view'),
|
|
||||||
createEvent('evt2', 'session_start'),
|
|
||||||
];
|
|
||||||
const series = [
|
|
||||||
createSeries(['screen_view'], events[0]!, [
|
|
||||||
{ date: '2025-01-01', count: 100 },
|
|
||||||
]),
|
|
||||||
createSeries(['session_start'], events[1]!, [
|
|
||||||
{ date: '2025-01-01', count: 50 },
|
|
||||||
]),
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = withFormula(
|
|
||||||
createChartInput({ formula: 'A/B', events }),
|
|
||||||
series,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result).toHaveLength(1);
|
|
||||||
expect(result[0]?.data[0]?.count).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should combine two events with addition formula', () => {
|
|
||||||
const events = [
|
|
||||||
createEvent('evt1', 'event1'),
|
|
||||||
createEvent('evt2', 'event2'),
|
|
||||||
];
|
|
||||||
const series = [
|
|
||||||
createSeries(['event1'], events[0]!, [
|
|
||||||
{ date: '2025-01-01', count: 10 },
|
|
||||||
]),
|
|
||||||
createSeries(['event2'], events[1]!, [
|
|
||||||
{ date: '2025-01-01', count: 20 },
|
|
||||||
]),
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = withFormula(
|
|
||||||
createChartInput({ formula: 'A+B', events }),
|
|
||||||
series,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result[0]?.data[0]?.count).toBe(30);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle three events', () => {
|
|
||||||
const events = [
|
|
||||||
createEvent('evt1', 'event1'),
|
|
||||||
createEvent('evt2', 'event2'),
|
|
||||||
createEvent('evt3', 'event3'),
|
|
||||||
];
|
|
||||||
const series = [
|
|
||||||
createSeries(['event1'], events[0]!, [
|
|
||||||
{ date: '2025-01-01', count: 10 },
|
|
||||||
]),
|
|
||||||
createSeries(['event2'], events[1]!, [
|
|
||||||
{ date: '2025-01-01', count: 20 },
|
|
||||||
]),
|
|
||||||
createSeries(['event3'], events[2]!, [
|
|
||||||
{ date: '2025-01-01', count: 30 },
|
|
||||||
]),
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = withFormula(
|
|
||||||
createChartInput({ formula: 'A+B+C', events }),
|
|
||||||
series,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result[0]?.data[0]?.count).toBe(60);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle missing data points with 0', () => {
|
|
||||||
const events = [
|
|
||||||
createEvent('evt1', 'event1'),
|
|
||||||
createEvent('evt2', 'event2'),
|
|
||||||
];
|
|
||||||
const series = [
|
|
||||||
createSeries(['event1'], events[0]!, [
|
|
||||||
{ date: '2025-01-01', count: 10 },
|
|
||||||
{ date: '2025-01-02', count: 20 },
|
|
||||||
]),
|
|
||||||
createSeries(['event2'], events[1]!, [
|
|
||||||
{ date: '2025-01-01', count: 5 },
|
|
||||||
// Missing 2025-01-02
|
|
||||||
]),
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = withFormula(
|
|
||||||
createChartInput({ formula: 'A+B', events }),
|
|
||||||
series,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result[0]?.data[0]?.count).toBe(15); // 10 + 5
|
|
||||||
expect(result[0]?.data[1]?.count).toBe(20); // 20 + 0 (missing)
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('multiple events, with breakdown', () => {
|
|
||||||
it('should match series by breakdown values and apply formula', () => {
|
|
||||||
const events = [
|
|
||||||
createEvent('evt1', 'screen_view'),
|
|
||||||
createEvent('evt2', 'session_start'),
|
|
||||||
];
|
|
||||||
const series = [
|
|
||||||
// iOS breakdown
|
|
||||||
createSeries(['iOS'], events[0]!, [{ date: '2025-01-01', count: 100 }]),
|
|
||||||
createSeries(['iOS'], events[1]!, [{ date: '2025-01-01', count: 50 }]),
|
|
||||||
// Android breakdown
|
|
||||||
createSeries(['Android'], events[0]!, [
|
|
||||||
{ date: '2025-01-01', count: 200 },
|
|
||||||
]),
|
|
||||||
createSeries(['Android'], events[1]!, [
|
|
||||||
{ date: '2025-01-01', count: 100 },
|
|
||||||
]),
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = withFormula(
|
|
||||||
createChartInput({ formula: 'A/B', events }),
|
|
||||||
series,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result).toHaveLength(2);
|
|
||||||
// iOS: 100/50 = 2
|
|
||||||
expect(result[0]?.name).toEqual(['iOS']);
|
|
||||||
expect(result[0]?.data[0]?.count).toBe(2);
|
|
||||||
// Android: 200/100 = 2
|
|
||||||
expect(result[1]?.name).toEqual(['Android']);
|
|
||||||
expect(result[1]?.data[0]?.count).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle multiple breakdown values matching', () => {
|
|
||||||
const events = [
|
|
||||||
createEvent('evt1', 'screen_view'),
|
|
||||||
createEvent('evt2', 'session_start'),
|
|
||||||
];
|
|
||||||
const series = [
|
|
||||||
createSeries(['iOS', 'US'], events[0]!, [
|
|
||||||
{ date: '2025-01-01', count: 100 },
|
|
||||||
]),
|
|
||||||
createSeries(['iOS', 'US'], events[1]!, [
|
|
||||||
{ date: '2025-01-01', count: 50 },
|
|
||||||
]),
|
|
||||||
createSeries(['Android', 'US'], events[0]!, [
|
|
||||||
{ date: '2025-01-01', count: 200 },
|
|
||||||
]),
|
|
||||||
createSeries(['Android', 'US'], events[1]!, [
|
|
||||||
{ date: '2025-01-01', count: 100 },
|
|
||||||
]),
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = withFormula(
|
|
||||||
createChartInput({ formula: 'A/B', events }),
|
|
||||||
series,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result).toHaveLength(2);
|
|
||||||
expect(result[0]?.name).toEqual(['iOS', 'US']);
|
|
||||||
expect(result[0]?.data[0]?.count).toBe(2);
|
|
||||||
expect(result[1]?.name).toEqual(['Android', 'US']);
|
|
||||||
expect(result[1]?.data[0]?.count).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle different date ranges across breakdown groups', () => {
|
|
||||||
const events = [
|
|
||||||
createEvent('evt1', 'screen_view'),
|
|
||||||
createEvent('evt2', 'session_start'),
|
|
||||||
];
|
|
||||||
const series = [
|
|
||||||
createSeries(['iOS'], events[0]!, [
|
|
||||||
{ date: '2025-01-01', count: 100 },
|
|
||||||
{ date: '2025-01-02', count: 200 },
|
|
||||||
]),
|
|
||||||
createSeries(['iOS'], events[1]!, [
|
|
||||||
{ date: '2025-01-01', count: 50 },
|
|
||||||
{ date: '2025-01-02', count: 100 },
|
|
||||||
]),
|
|
||||||
createSeries(['Android'], events[0]!, [
|
|
||||||
{ date: '2025-01-01', count: 300 },
|
|
||||||
// Missing 2025-01-02
|
|
||||||
]),
|
|
||||||
createSeries(['Android'], events[1]!, [
|
|
||||||
{ date: '2025-01-01', count: 150 },
|
|
||||||
{ date: '2025-01-02', count: 200 },
|
|
||||||
]),
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = withFormula(
|
|
||||||
createChartInput({ formula: 'A/B', events }),
|
|
||||||
series,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result).toHaveLength(2);
|
|
||||||
// iOS group
|
|
||||||
expect(result[0]?.name).toEqual(['iOS']);
|
|
||||||
expect(result[0]?.data[0]?.count).toBe(2); // 100/50
|
|
||||||
expect(result[0]?.data[1]?.count).toBe(2); // 200/100
|
|
||||||
// Android group
|
|
||||||
expect(result[1]?.name).toEqual(['Android']);
|
|
||||||
expect(result[1]?.data[0]?.count).toBe(2); // 300/150
|
|
||||||
expect(result[1]?.data[1]?.count).toBe(0); // 0/200 = 0 (missing A)
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('complex formulas', () => {
|
|
||||||
it('should handle complex expressions', () => {
|
|
||||||
const events = [
|
|
||||||
createEvent('evt1', 'event1'),
|
|
||||||
createEvent('evt2', 'event2'),
|
|
||||||
createEvent('evt3', 'event3'),
|
|
||||||
];
|
|
||||||
const series = [
|
|
||||||
createSeries(['event1'], events[0]!, [
|
|
||||||
{ date: '2025-01-01', count: 10 },
|
|
||||||
]),
|
|
||||||
createSeries(['event2'], events[1]!, [
|
|
||||||
{ date: '2025-01-01', count: 20 },
|
|
||||||
]),
|
|
||||||
createSeries(['event3'], events[2]!, [
|
|
||||||
{ date: '2025-01-01', count: 30 },
|
|
||||||
]),
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = withFormula(
|
|
||||||
createChartInput({ formula: '(A+B)*C', events }),
|
|
||||||
series,
|
|
||||||
);
|
|
||||||
|
|
||||||
// (10+20)*30 = 900
|
|
||||||
expect(result[0]?.data[0]?.count).toBe(900);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle percentage calculations', () => {
|
|
||||||
const events = [
|
|
||||||
createEvent('evt1', 'screen_view'),
|
|
||||||
createEvent('evt2', 'session_start'),
|
|
||||||
];
|
|
||||||
const series = [
|
|
||||||
createSeries(['screen_view'], events[0]!, [
|
|
||||||
{ date: '2025-01-01', count: 75 },
|
|
||||||
]),
|
|
||||||
createSeries(['session_start'], events[1]!, [
|
|
||||||
{ date: '2025-01-01', count: 100 },
|
|
||||||
]),
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = withFormula(
|
|
||||||
createChartInput({ formula: '(A/B)*100', events }),
|
|
||||||
series,
|
|
||||||
);
|
|
||||||
|
|
||||||
// (75/100)*100 = 75
|
|
||||||
expect(result[0]?.data[0]?.count).toBe(75);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('error handling', () => {
|
|
||||||
it('should handle invalid formulas gracefully', () => {
|
|
||||||
const events = [createEvent('evt1', 'event1')];
|
|
||||||
const series = [
|
|
||||||
createSeries(['event1'], events[0]!, [
|
|
||||||
{ date: '2025-01-01', count: 10 },
|
|
||||||
]),
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = withFormula(
|
|
||||||
createChartInput({ formula: 'invalid formula', events }),
|
|
||||||
series,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Should return 0 for invalid formulas
|
|
||||||
expect(result[0]?.data[0]?.count).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle division by zero', () => {
|
|
||||||
const events = [
|
|
||||||
createEvent('evt1', 'event1'),
|
|
||||||
createEvent('evt2', 'event2'),
|
|
||||||
];
|
|
||||||
const series = [
|
|
||||||
createSeries(['event1'], events[0]!, [
|
|
||||||
{ date: '2025-01-01', count: 10 },
|
|
||||||
]),
|
|
||||||
createSeries(['event2'], events[1]!, [
|
|
||||||
{ date: '2025-01-01', count: 0 },
|
|
||||||
]),
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = withFormula(
|
|
||||||
createChartInput({ formula: 'A/B', events }),
|
|
||||||
series,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Division by zero should result in 0 (Infinity -> 0)
|
|
||||||
expect(result[0]?.data[0]?.count).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('real-world scenario: article hit ratio', () => {
|
|
||||||
it('should calculate hit ratio per article path', () => {
|
|
||||||
const events = [
|
|
||||||
createEvent('evt1', 'screen_view'),
|
|
||||||
createEvent('evt2', 'article_card_seen'),
|
|
||||||
];
|
|
||||||
const series = [
|
|
||||||
// Article 1
|
|
||||||
createSeries(['/articles/1'], events[0]!, [
|
|
||||||
{ date: '2025-01-01', count: 1000 },
|
|
||||||
]),
|
|
||||||
createSeries(['/articles/1'], events[1]!, [
|
|
||||||
{ date: '2025-01-01', count: 100 },
|
|
||||||
]),
|
|
||||||
// Article 2
|
|
||||||
createSeries(['/articles/2'], events[0]!, [
|
|
||||||
{ date: '2025-01-01', count: 500 },
|
|
||||||
]),
|
|
||||||
createSeries(['/articles/2'], events[1]!, [
|
|
||||||
{ date: '2025-01-01', count: 200 },
|
|
||||||
]),
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = withFormula(
|
|
||||||
createChartInput({ formula: 'A/B', events }),
|
|
||||||
series,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result).toHaveLength(2);
|
|
||||||
// Article 1: 1000/100 = 10
|
|
||||||
expect(result[0]?.name).toEqual(['/articles/1']);
|
|
||||||
expect(result[0]?.data[0]?.count).toBe(10);
|
|
||||||
// Article 2: 500/200 = 2.5
|
|
||||||
expect(result[1]?.name).toEqual(['/articles/2']);
|
|
||||||
expect(result[1]?.data[0]?.count).toBe(2.5);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,514 +0,0 @@
|
|||||||
import * as mathjs from 'mathjs';
|
|
||||||
import { last, reverse } from 'ramda';
|
|
||||||
import sqlstring from 'sqlstring';
|
|
||||||
|
|
||||||
import type { ISerieDataItem } from '@openpanel/common';
|
|
||||||
import {
|
|
||||||
average,
|
|
||||||
getPreviousMetric,
|
|
||||||
groupByLabels,
|
|
||||||
max,
|
|
||||||
min,
|
|
||||||
round,
|
|
||||||
slug,
|
|
||||||
sum,
|
|
||||||
} from '@openpanel/common';
|
|
||||||
import { alphabetIds } from '@openpanel/constants';
|
|
||||||
import {
|
|
||||||
TABLE_NAMES,
|
|
||||||
chQuery,
|
|
||||||
createSqlBuilder,
|
|
||||||
formatClickhouseDate,
|
|
||||||
getChartPrevStartEndDate,
|
|
||||||
getChartSql,
|
|
||||||
getChartStartEndDate,
|
|
||||||
getEventFiltersWhereClause,
|
|
||||||
getOrganizationSubscriptionChartEndDate,
|
|
||||||
getSettingsForProject,
|
|
||||||
} from '@openpanel/db';
|
|
||||||
import type {
|
|
||||||
FinalChart,
|
|
||||||
IChartEvent,
|
|
||||||
IChartInput,
|
|
||||||
IChartInputWithDates,
|
|
||||||
IGetChartDataInput,
|
|
||||||
} from '@openpanel/validation';
|
|
||||||
|
|
||||||
export function withFormula(
|
|
||||||
{ formula, events }: IChartInput,
|
|
||||||
series: Awaited<ReturnType<typeof getChartSerie>>,
|
|
||||||
) {
|
|
||||||
if (!formula) {
|
|
||||||
return series;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!series || series.length === 0) {
|
|
||||||
return series;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!series[0]?.data) {
|
|
||||||
return series;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Formulas always use alphabet IDs (A, B, C, etc.), not event IDs
|
|
||||||
// Group series by breakdown values (the name array)
|
|
||||||
// This allows us to match series from different events that have the same breakdown values
|
|
||||||
|
|
||||||
// Detect if we have breakdowns: when there are no breakdowns, name arrays contain event names
|
|
||||||
// When there are breakdowns, name arrays contain breakdown values (not event names)
|
|
||||||
const hasBreakdowns = series.some(
|
|
||||||
(serie) =>
|
|
||||||
serie.name.length > 0 &&
|
|
||||||
!events.some(
|
|
||||||
(event) =>
|
|
||||||
serie.name[0] === event.name || serie.name[0] === event.displayName,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const seriesByBreakdown = new Map<string, typeof series>();
|
|
||||||
|
|
||||||
series.forEach((serie) => {
|
|
||||||
let breakdownKey: string;
|
|
||||||
|
|
||||||
if (hasBreakdowns) {
|
|
||||||
// With breakdowns: use the entire name array as the breakdown key
|
|
||||||
// The name array contains breakdown values (e.g., ["iOS"], ["Android"])
|
|
||||||
breakdownKey = serie.name.join(':::');
|
|
||||||
} else {
|
|
||||||
// Without breakdowns: group all series together regardless of event name
|
|
||||||
// This allows formulas to combine multiple events
|
|
||||||
breakdownKey = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!seriesByBreakdown.has(breakdownKey)) {
|
|
||||||
seriesByBreakdown.set(breakdownKey, []);
|
|
||||||
}
|
|
||||||
seriesByBreakdown.get(breakdownKey)!.push(serie);
|
|
||||||
});
|
|
||||||
|
|
||||||
// For each breakdown group, apply the formula
|
|
||||||
const result: typeof series = [];
|
|
||||||
|
|
||||||
for (const [breakdownKey, breakdownSeries] of seriesByBreakdown) {
|
|
||||||
// Group series by event to ensure we have one series per event
|
|
||||||
const seriesByEvent = new Map<string, (typeof series)[number]>();
|
|
||||||
|
|
||||||
breakdownSeries.forEach((serie) => {
|
|
||||||
const eventId = serie.event.id ?? serie.event.name;
|
|
||||||
// If we already have a series for this event in this breakdown group, skip it
|
|
||||||
// (shouldn't happen, but just in case)
|
|
||||||
if (!seriesByEvent.has(eventId)) {
|
|
||||||
seriesByEvent.set(eventId, serie);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get all unique dates across all series in this breakdown group
|
|
||||||
const allDates = new Set<string>();
|
|
||||||
breakdownSeries.forEach((serie) => {
|
|
||||||
serie.data.forEach((item) => {
|
|
||||||
allDates.add(item.date);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sort dates chronologically
|
|
||||||
const sortedDates = Array.from(allDates).sort(
|
|
||||||
(a, b) => new Date(a).getTime() - new Date(b).getTime(),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Apply formula for each date, matching series by event index
|
|
||||||
const formulaData = sortedDates.map((date) => {
|
|
||||||
const scope: Record<string, number> = {};
|
|
||||||
|
|
||||||
// Build scope using alphabet IDs (A, B, C, etc.) for each event
|
|
||||||
// This matches how formulas are written (e.g., "A*100", "A/B", "A+B-C")
|
|
||||||
events.forEach((event, eventIndex) => {
|
|
||||||
const readableId = alphabetIds[eventIndex];
|
|
||||||
if (!readableId) {
|
|
||||||
throw new Error('no alphabet id for serie in withFormula');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the series for this event in this breakdown group
|
|
||||||
const eventId = event.id ?? event.name;
|
|
||||||
const matchingSerie = seriesByEvent.get(eventId);
|
|
||||||
|
|
||||||
// Find the data point for this date
|
|
||||||
// If the series doesn't exist or the date is missing, use 0
|
|
||||||
const dataPoint = matchingSerie?.data.find((d) => d.date === date);
|
|
||||||
scope[readableId] = dataPoint?.count ?? 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Evaluate the formula with the scope
|
|
||||||
let count: number;
|
|
||||||
try {
|
|
||||||
count = mathjs.parse(formula).compile().evaluate(scope) as number;
|
|
||||||
} catch (error) {
|
|
||||||
// If formula evaluation fails, return 0
|
|
||||||
count = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
date,
|
|
||||||
count:
|
|
||||||
Number.isNaN(count) || !Number.isFinite(count) ? 0 : round(count, 2),
|
|
||||||
total_count: breakdownSeries[0]?.data.find((d) => d.date === date)
|
|
||||||
?.total_count,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Use the first series as a template, but replace its data with formula results
|
|
||||||
// Preserve the breakdown labels (name array) from the original series
|
|
||||||
const templateSerie = breakdownSeries[0]!;
|
|
||||||
result.push({
|
|
||||||
...templateSerie,
|
|
||||||
data: formulaData,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
function fillFunnel(funnel: { level: number; count: number }[], steps: number) {
|
|
||||||
const filled = Array.from({ length: steps }, (_, index) => {
|
|
||||||
const level = index + 1;
|
|
||||||
const matchingResult = funnel.find((res) => res.level === level);
|
|
||||||
return {
|
|
||||||
level,
|
|
||||||
count: matchingResult ? matchingResult.count : 0,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Accumulate counts from top to bottom of the funnel
|
|
||||||
for (let i = filled.length - 1; i >= 0; i--) {
|
|
||||||
const step = filled[i];
|
|
||||||
const prevStep = filled[i + 1];
|
|
||||||
// If there's a previous step, add the count to the current step
|
|
||||||
if (step && prevStep) {
|
|
||||||
step.count += prevStep.count;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return filled.reverse();
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getFunnelData({
|
|
||||||
projectId,
|
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
...payload
|
|
||||||
}: IChartInput) {
|
|
||||||
const funnelWindow = (payload.funnelWindow || 24) * 3600;
|
|
||||||
const funnelGroup =
|
|
||||||
payload.funnelGroup === 'profile_id'
|
|
||||||
? [`COALESCE(nullIf(s.profile_id, ''), e.profile_id)`, 'profile_id']
|
|
||||||
: ['session_id', 'session_id'];
|
|
||||||
|
|
||||||
if (!startDate || !endDate) {
|
|
||||||
throw new Error('startDate and endDate are required');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (payload.events.length === 0) {
|
|
||||||
return {
|
|
||||||
totalSessions: 0,
|
|
||||||
steps: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const funnels = payload.events.map((event) => {
|
|
||||||
const { sb, getWhere } = createSqlBuilder();
|
|
||||||
sb.where = getEventFiltersWhereClause(event.filters);
|
|
||||||
sb.where.name = `name = ${sqlstring.escape(event.name)}`;
|
|
||||||
return getWhere().replace('WHERE ', '');
|
|
||||||
});
|
|
||||||
|
|
||||||
const commonWhere = `project_id = ${sqlstring.escape(projectId)} AND
|
|
||||||
created_at >= '${formatClickhouseDate(startDate)}' AND
|
|
||||||
created_at <= '${formatClickhouseDate(endDate)}'`;
|
|
||||||
|
|
||||||
const innerSql = `SELECT
|
|
||||||
${funnelGroup[0]} AS ${funnelGroup[1]},
|
|
||||||
windowFunnel(${funnelWindow}, 'strict_increase')(toUnixTimestamp(created_at), ${funnels.join(', ')}) AS level
|
|
||||||
FROM ${TABLE_NAMES.events} e
|
|
||||||
${funnelGroup[0] === 'session_id' ? '' : `LEFT JOIN (SELECT profile_id, id FROM sessions WHERE ${commonWhere}) AS s ON s.id = e.session_id`}
|
|
||||||
WHERE
|
|
||||||
${commonWhere} AND
|
|
||||||
name IN (${payload.events.map((event) => sqlstring.escape(event.name)).join(', ')})
|
|
||||||
GROUP BY ${funnelGroup[0]}`;
|
|
||||||
|
|
||||||
const sql = `SELECT level, count() AS count FROM (${innerSql}) WHERE level != 0 GROUP BY level ORDER BY level DESC`;
|
|
||||||
|
|
||||||
const funnel = await chQuery<{ level: number; count: number }>(sql);
|
|
||||||
const maxLevel = payload.events.length;
|
|
||||||
const filledFunnelRes = fillFunnel(funnel, maxLevel);
|
|
||||||
|
|
||||||
const totalSessions = last(filledFunnelRes)?.count ?? 0;
|
|
||||||
const steps = reverse(filledFunnelRes).reduce(
|
|
||||||
(acc, item, index, list) => {
|
|
||||||
const prev = list[index - 1] ?? { count: totalSessions };
|
|
||||||
const event = payload.events[item.level - 1]!;
|
|
||||||
return [
|
|
||||||
...acc,
|
|
||||||
{
|
|
||||||
event: {
|
|
||||||
...event,
|
|
||||||
displayName: event.displayName ?? event.name,
|
|
||||||
},
|
|
||||||
count: item.count,
|
|
||||||
percent: (item.count / totalSessions) * 100,
|
|
||||||
dropoffCount: prev.count - item.count,
|
|
||||||
dropoffPercent: 100 - (item.count / prev.count) * 100,
|
|
||||||
previousCount: prev.count,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
},
|
|
||||||
[] as {
|
|
||||||
event: IChartEvent & { displayName: string };
|
|
||||||
count: number;
|
|
||||||
percent: number;
|
|
||||||
dropoffCount: number;
|
|
||||||
dropoffPercent: number;
|
|
||||||
previousCount: number;
|
|
||||||
}[],
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
totalSessions,
|
|
||||||
steps,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getChartSerie(
|
|
||||||
payload: IGetChartDataInput,
|
|
||||||
timezone: string,
|
|
||||||
) {
|
|
||||||
let result = await chQuery<ISerieDataItem>(
|
|
||||||
getChartSql({ ...payload, timezone }),
|
|
||||||
{
|
|
||||||
session_timezone: timezone,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result.length === 0 && payload.breakdowns.length > 0) {
|
|
||||||
result = await chQuery<ISerieDataItem>(
|
|
||||||
getChartSql({
|
|
||||||
...payload,
|
|
||||||
breakdowns: [],
|
|
||||||
timezone,
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
session_timezone: timezone,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return groupByLabels(result).map((serie) => {
|
|
||||||
return {
|
|
||||||
...serie,
|
|
||||||
event: payload.event,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export type IGetChartSerie = Awaited<ReturnType<typeof getChartSeries>>[number];
|
|
||||||
export async function getChartSeries(
|
|
||||||
input: IChartInputWithDates,
|
|
||||||
timezone: string,
|
|
||||||
) {
|
|
||||||
const series = (
|
|
||||||
await Promise.all(
|
|
||||||
input.events.map(async (event) =>
|
|
||||||
getChartSerie(
|
|
||||||
{
|
|
||||||
...input,
|
|
||||||
event,
|
|
||||||
},
|
|
||||||
timezone,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
).flat();
|
|
||||||
|
|
||||||
try {
|
|
||||||
return withFormula(input, series);
|
|
||||||
} catch (e) {
|
|
||||||
return series;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getChart(input: IChartInput) {
|
|
||||||
const { timezone } = await getSettingsForProject(input.projectId);
|
|
||||||
const currentPeriod = getChartStartEndDate(input, timezone);
|
|
||||||
const previousPeriod = getChartPrevStartEndDate(currentPeriod);
|
|
||||||
|
|
||||||
const endDate = await getOrganizationSubscriptionChartEndDate(
|
|
||||||
input.projectId,
|
|
||||||
currentPeriod.endDate,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (endDate) {
|
|
||||||
currentPeriod.endDate = endDate;
|
|
||||||
}
|
|
||||||
|
|
||||||
const promises = [getChartSeries({ ...input, ...currentPeriod }, timezone)];
|
|
||||||
|
|
||||||
if (input.previous) {
|
|
||||||
promises.push(
|
|
||||||
getChartSeries(
|
|
||||||
{
|
|
||||||
...input,
|
|
||||||
...previousPeriod,
|
|
||||||
},
|
|
||||||
timezone,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const getSerieId = (serie: IGetChartSerie) =>
|
|
||||||
[slug(serie.name.join('-')), serie.event.id].filter(Boolean).join('-');
|
|
||||||
const result = await Promise.all(promises);
|
|
||||||
const series = result[0]!;
|
|
||||||
const previousSeries = result[1];
|
|
||||||
const limit = input.limit || 300;
|
|
||||||
const offset = input.offset || 0;
|
|
||||||
const includeEventAlphaId = input.events.length > 1;
|
|
||||||
const final: FinalChart = {
|
|
||||||
series: series.map((serie, index) => {
|
|
||||||
const eventIndex = input.events.findIndex(
|
|
||||||
(event) => event.id === serie.event.id,
|
|
||||||
);
|
|
||||||
const alphaId = alphabetIds[eventIndex];
|
|
||||||
const previousSerie = previousSeries?.find(
|
|
||||||
(prevSerie) => getSerieId(prevSerie) === getSerieId(serie),
|
|
||||||
);
|
|
||||||
const metrics = {
|
|
||||||
sum: sum(serie.data.map((item) => item.count)),
|
|
||||||
average: round(average(serie.data.map((item) => item.count)), 2),
|
|
||||||
min: min(serie.data.map((item) => item.count)),
|
|
||||||
max: max(serie.data.map((item) => item.count)),
|
|
||||||
count: serie.data[0]?.total_count, // We can grab any since all are the same
|
|
||||||
};
|
|
||||||
const event = {
|
|
||||||
id: serie.event.id,
|
|
||||||
name: serie.event.displayName || serie.event.name,
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: getSerieId(serie),
|
|
||||||
names:
|
|
||||||
input.breakdowns.length === 0 && serie.event.displayName
|
|
||||||
? [serie.event.displayName]
|
|
||||||
: includeEventAlphaId
|
|
||||||
? [`(${alphaId}) ${serie.name[0]}`, ...serie.name.slice(1)]
|
|
||||||
: serie.name,
|
|
||||||
event,
|
|
||||||
metrics: {
|
|
||||||
...metrics,
|
|
||||||
...(input.previous
|
|
||||||
? {
|
|
||||||
previous: {
|
|
||||||
sum: getPreviousMetric(
|
|
||||||
metrics.sum,
|
|
||||||
previousSerie
|
|
||||||
? sum(previousSerie?.data.map((item) => item.count))
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
average: getPreviousMetric(
|
|
||||||
metrics.average,
|
|
||||||
previousSerie
|
|
||||||
? round(
|
|
||||||
average(
|
|
||||||
previousSerie?.data.map((item) => item.count),
|
|
||||||
),
|
|
||||||
2,
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
min: getPreviousMetric(
|
|
||||||
metrics.sum,
|
|
||||||
previousSerie
|
|
||||||
? min(previousSerie?.data.map((item) => item.count))
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
max: getPreviousMetric(
|
|
||||||
metrics.sum,
|
|
||||||
previousSerie
|
|
||||||
? max(previousSerie?.data.map((item) => item.count))
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
count: getPreviousMetric(
|
|
||||||
metrics.count ?? 0,
|
|
||||||
previousSerie?.data[0]?.total_count ?? null,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
},
|
|
||||||
data: serie.data.map((item, index) => ({
|
|
||||||
date: item.date,
|
|
||||||
count: item.count ?? 0,
|
|
||||||
previous: previousSerie?.data[index]
|
|
||||||
? getPreviousMetric(
|
|
||||||
item.count ?? 0,
|
|
||||||
previousSerie?.data[index]?.count ?? null,
|
|
||||||
)
|
|
||||||
: undefined,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
metrics: {
|
|
||||||
sum: 0,
|
|
||||||
average: 0,
|
|
||||||
min: 0,
|
|
||||||
max: 0,
|
|
||||||
count: undefined,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Sort by sum
|
|
||||||
final.series = final.series
|
|
||||||
.sort((a, b) => {
|
|
||||||
if (input.chartType === 'linear') {
|
|
||||||
const sumA = a.data.reduce((acc, item) => acc + (item.count ?? 0), 0);
|
|
||||||
const sumB = b.data.reduce((acc, item) => acc + (item.count ?? 0), 0);
|
|
||||||
return sumB - sumA;
|
|
||||||
}
|
|
||||||
return (b.metrics[input.metric] ?? 0) - (a.metrics[input.metric] ?? 0);
|
|
||||||
})
|
|
||||||
.slice(offset, limit ? offset + limit : series.length);
|
|
||||||
|
|
||||||
final.metrics.sum = sum(final.series.map((item) => item.metrics.sum));
|
|
||||||
final.metrics.average = round(
|
|
||||||
average(final.series.map((item) => item.metrics.average)),
|
|
||||||
2,
|
|
||||||
);
|
|
||||||
final.metrics.min = min(final.series.map((item) => item.metrics.min));
|
|
||||||
final.metrics.max = max(final.series.map((item) => item.metrics.max));
|
|
||||||
if (input.previous) {
|
|
||||||
final.metrics.previous = {
|
|
||||||
sum: getPreviousMetric(
|
|
||||||
final.metrics.sum,
|
|
||||||
sum(final.series.map((item) => item.metrics.previous?.sum?.value ?? 0)),
|
|
||||||
),
|
|
||||||
average: getPreviousMetric(
|
|
||||||
final.metrics.average,
|
|
||||||
round(
|
|
||||||
average(
|
|
||||||
final.series.map(
|
|
||||||
(item) => item.metrics.previous?.average?.value ?? 0,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
2,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
min: getPreviousMetric(
|
|
||||||
final.metrics.min,
|
|
||||||
min(final.series.map((item) => item.metrics.previous?.min?.value ?? 0)),
|
|
||||||
),
|
|
||||||
max: getPreviousMetric(
|
|
||||||
final.metrics.max,
|
|
||||||
max(final.series.map((item) => item.metrics.previous?.max?.value ?? 0)),
|
|
||||||
),
|
|
||||||
count: undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return final;
|
|
||||||
}
|
|
||||||
@@ -10,22 +10,32 @@ import {
|
|||||||
chQuery,
|
chQuery,
|
||||||
clix,
|
clix,
|
||||||
conversionService,
|
conversionService,
|
||||||
|
createSqlBuilder,
|
||||||
db,
|
db,
|
||||||
|
formatClickhouseDate,
|
||||||
funnelService,
|
funnelService,
|
||||||
getChartPrevStartEndDate,
|
getChartPrevStartEndDate,
|
||||||
getChartStartEndDate,
|
getChartStartEndDate,
|
||||||
|
getEventFiltersWhereClause,
|
||||||
getEventMetasCached,
|
getEventMetasCached,
|
||||||
|
getProfilesCached,
|
||||||
getSelectPropertyKey,
|
getSelectPropertyKey,
|
||||||
getSettingsForProject,
|
getSettingsForProject,
|
||||||
|
onlyReportEvents,
|
||||||
} from '@openpanel/db';
|
} from '@openpanel/db';
|
||||||
import {
|
import {
|
||||||
|
type IChartEvent,
|
||||||
|
zChartEvent,
|
||||||
|
zChartEventFilter,
|
||||||
zChartInput,
|
zChartInput,
|
||||||
|
zChartSeries,
|
||||||
zCriteria,
|
zCriteria,
|
||||||
zRange,
|
zRange,
|
||||||
zTimeInterval,
|
zTimeInterval,
|
||||||
} from '@openpanel/validation';
|
} from '@openpanel/validation';
|
||||||
|
|
||||||
import { round } from '@openpanel/common';
|
import { round } from '@openpanel/common';
|
||||||
|
import { ChartEngine } from '@openpanel/db';
|
||||||
import {
|
import {
|
||||||
differenceInDays,
|
differenceInDays,
|
||||||
differenceInMonths,
|
differenceInMonths,
|
||||||
@@ -40,7 +50,6 @@ import {
|
|||||||
protectedProcedure,
|
protectedProcedure,
|
||||||
publicProcedure,
|
publicProcedure,
|
||||||
} from '../trpc';
|
} from '../trpc';
|
||||||
import { getChart } from './chart.helpers';
|
|
||||||
|
|
||||||
function utc(date: string | Date) {
|
function utc(date: string | Date) {
|
||||||
if (typeof date === 'string') {
|
if (typeof date === 'string') {
|
||||||
@@ -402,7 +411,8 @@ export const chartRouter = createTRPCRouter({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return getChart(input);
|
// Use new chart engine
|
||||||
|
return ChartEngine.execute(input);
|
||||||
}),
|
}),
|
||||||
cohort: protectedProcedure
|
cohort: protectedProcedure
|
||||||
.input(
|
.input(
|
||||||
@@ -532,6 +542,200 @@ export const chartRouter = createTRPCRouter({
|
|||||||
|
|
||||||
return processCohortData(cohortData, diffInterval);
|
return processCohortData(cohortData, diffInterval);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
getProfiles: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
projectId: z.string(),
|
||||||
|
date: z.string().describe('The date for the data point (ISO string)'),
|
||||||
|
interval: zTimeInterval.default('day'),
|
||||||
|
series: zChartSeries,
|
||||||
|
breakdowns: z.record(z.string(), z.string()).optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.query(async ({ input }) => {
|
||||||
|
const { timezone } = await getSettingsForProject(input.projectId);
|
||||||
|
const { projectId, date, series } = input;
|
||||||
|
const limit = 100;
|
||||||
|
const serie = series[0];
|
||||||
|
|
||||||
|
if (!serie) {
|
||||||
|
throw new Error('Series not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (serie.type !== 'event') {
|
||||||
|
throw new Error('Series must be an event');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the date range for the specific interval bucket
|
||||||
|
const dateObj = new Date(date);
|
||||||
|
// Build query to get unique profile_ids for this time bucket
|
||||||
|
const { sb, getSql } = createSqlBuilder();
|
||||||
|
|
||||||
|
sb.select.profile_id = 'DISTINCT profile_id';
|
||||||
|
sb.where = getEventFiltersWhereClause(serie.filters);
|
||||||
|
sb.where.projectId = `project_id = ${sqlstring.escape(projectId)}`;
|
||||||
|
sb.where.dateRange = `${clix.toStartOf('created_at', input.interval)} = ${clix.toDate(sqlstring.escape(formatClickhouseDate(dateObj)), input.interval)}`;
|
||||||
|
if (serie.name !== '*') {
|
||||||
|
sb.where.eventName = `name = ${sqlstring.escape(serie.name)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('> breakdowns', input.breakdowns);
|
||||||
|
if (input.breakdowns) {
|
||||||
|
Object.entries(input.breakdowns).forEach(([key, value]) => {
|
||||||
|
sb.where[`breakdown_${key}`] = `${key} = ${sqlstring.escape(value)}`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// // Handle breakdowns if provided
|
||||||
|
// const anyBreakdownOnProfile = breakdowns.some((breakdown) =>
|
||||||
|
// breakdown.name.startsWith('profile.'),
|
||||||
|
// );
|
||||||
|
// const anyFilterOnProfile = [...event.filters, ...filters].some((filter) =>
|
||||||
|
// filter.name.startsWith('profile.'),
|
||||||
|
// );
|
||||||
|
|
||||||
|
// if (anyFilterOnProfile || anyBreakdownOnProfile) {
|
||||||
|
// sb.joins.profiles = `LEFT ANY JOIN (SELECT
|
||||||
|
// id as "profile.id",
|
||||||
|
// email as "profile.email",
|
||||||
|
// first_name as "profile.first_name",
|
||||||
|
// last_name as "profile.last_name",
|
||||||
|
// properties as "profile.properties"
|
||||||
|
// FROM ${TABLE_NAMES.profiles} FINAL WHERE project_id = ${sqlstring.escape(projectId)}) as profile on profile.id = profile_id`;
|
||||||
|
// }
|
||||||
|
|
||||||
|
// Apply breakdown filters if provided
|
||||||
|
// breakdowns.forEach((breakdown) => {
|
||||||
|
// // This is simplified - in reality we'd need to match the breakdown value
|
||||||
|
// // For now, we'll just get all profiles for the time bucket
|
||||||
|
// });
|
||||||
|
|
||||||
|
// Get unique profile IDs
|
||||||
|
const profileIds = await chQuery<{ profile_id: string }>(getSql());
|
||||||
|
if (profileIds.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch profile details
|
||||||
|
const ids = profileIds.map((p) => p.profile_id).filter(Boolean);
|
||||||
|
const profiles = await getProfilesCached(ids, projectId);
|
||||||
|
|
||||||
|
return profiles;
|
||||||
|
}),
|
||||||
|
|
||||||
|
getFunnelProfiles: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
projectId: z.string(),
|
||||||
|
startDate: z.string().nullish(),
|
||||||
|
endDate: z.string().nullish(),
|
||||||
|
series: zChartSeries,
|
||||||
|
stepIndex: z.number().describe('0-based index of the funnel step'),
|
||||||
|
showDropoffs: z
|
||||||
|
.boolean()
|
||||||
|
.optional()
|
||||||
|
.default(false)
|
||||||
|
.describe(
|
||||||
|
'If true, show users who dropped off at this step. If false, show users who completed at least this step.',
|
||||||
|
),
|
||||||
|
funnelWindow: z.number().optional(),
|
||||||
|
funnelGroup: z.string().optional(),
|
||||||
|
breakdowns: z.array(z.object({ name: z.string() })).optional(),
|
||||||
|
range: zRange,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.query(async ({ input }) => {
|
||||||
|
const { timezone } = await getSettingsForProject(input.projectId);
|
||||||
|
const {
|
||||||
|
projectId,
|
||||||
|
series,
|
||||||
|
stepIndex,
|
||||||
|
showDropoffs = false,
|
||||||
|
funnelWindow,
|
||||||
|
funnelGroup,
|
||||||
|
breakdowns = [],
|
||||||
|
} = input;
|
||||||
|
|
||||||
|
const { startDate, endDate } = getChartStartEndDate(input, timezone);
|
||||||
|
|
||||||
|
// stepIndex is 0-based, but level is 1-based, so we need level >= stepIndex + 1
|
||||||
|
const targetLevel = stepIndex + 1;
|
||||||
|
|
||||||
|
const eventSeries = onlyReportEvents(series);
|
||||||
|
|
||||||
|
if (eventSeries.length === 0) {
|
||||||
|
throw new Error('At least one event series is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const funnelWindowSeconds = (funnelWindow || 24) * 3600;
|
||||||
|
const funnelWindowMilliseconds = funnelWindowSeconds * 1000;
|
||||||
|
|
||||||
|
// Use funnel service methods
|
||||||
|
const group = funnelService.getFunnelGroup(funnelGroup);
|
||||||
|
|
||||||
|
// Create sessions CTE if needed
|
||||||
|
const sessionsCte =
|
||||||
|
group[0] !== 'session_id'
|
||||||
|
? funnelService.buildSessionsCte({
|
||||||
|
projectId,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
timezone,
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Create funnel CTE using funnel service
|
||||||
|
const funnelCte = funnelService.buildFunnelCte({
|
||||||
|
projectId,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
eventSeries: eventSeries as IChartEvent[],
|
||||||
|
funnelWindowMilliseconds,
|
||||||
|
group,
|
||||||
|
timezone,
|
||||||
|
additionalSelects: ['profile_id'],
|
||||||
|
additionalGroupBy: ['profile_id'],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build main query
|
||||||
|
const query = clix(ch, timezone);
|
||||||
|
|
||||||
|
if (sessionsCte) {
|
||||||
|
funnelCte.leftJoin('sessions s', 's.sid = events.session_id');
|
||||||
|
query.with('sessions', sessionsCte);
|
||||||
|
}
|
||||||
|
|
||||||
|
query.with('funnel', funnelCte);
|
||||||
|
|
||||||
|
// Get distinct profile IDs
|
||||||
|
query
|
||||||
|
.select(['DISTINCT profile_id'])
|
||||||
|
.from('funnel')
|
||||||
|
.where('level', '!=', 0);
|
||||||
|
|
||||||
|
if (showDropoffs) {
|
||||||
|
// Show users who dropped off at this step (completed this step but not the next)
|
||||||
|
query.where('level', '=', targetLevel);
|
||||||
|
} else {
|
||||||
|
// Show users who completed at least this step
|
||||||
|
query.where('level', '>=', targetLevel);
|
||||||
|
}
|
||||||
|
|
||||||
|
const profileIdsResult = (await query.execute()) as {
|
||||||
|
profile_id: string;
|
||||||
|
}[];
|
||||||
|
|
||||||
|
if (profileIdsResult.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch profile details
|
||||||
|
const ids = profileIdsResult.map((p) => p.profile_id).filter(Boolean);
|
||||||
|
const profiles = await getProfilesCached(ids, projectId);
|
||||||
|
|
||||||
|
return profiles;
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
function processCohortData(
|
function processCohortData(
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export const reportRouter = createTRPCRouter({
|
|||||||
projectId: dashboard.projectId,
|
projectId: dashboard.projectId,
|
||||||
dashboardId,
|
dashboardId,
|
||||||
name: report.name,
|
name: report.name,
|
||||||
events: report.events,
|
events: report.series,
|
||||||
interval: report.interval,
|
interval: report.interval,
|
||||||
breakdowns: report.breakdowns,
|
breakdowns: report.breakdowns,
|
||||||
chartType: report.chartType,
|
chartType: report.chartType,
|
||||||
@@ -91,7 +91,7 @@ export const reportRouter = createTRPCRouter({
|
|||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
name: report.name,
|
name: report.name,
|
||||||
events: report.events,
|
events: report.series,
|
||||||
interval: report.interval,
|
interval: report.interval,
|
||||||
breakdowns: report.breakdowns,
|
breakdowns: report.breakdowns,
|
||||||
chartType: report.chartType,
|
chartType: report.chartType,
|
||||||
|
|||||||
@@ -57,12 +57,70 @@ export const zChartEvent = z.object({
|
|||||||
.default([])
|
.default([])
|
||||||
.describe('Filters applied specifically to this event'),
|
.describe('Filters applied specifically to this event'),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const zChartFormula = z.object({
|
||||||
|
id: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe('Unique identifier for the formula configuration'),
|
||||||
|
type: z.literal('formula'),
|
||||||
|
formula: z.string().describe('The formula expression (e.g., A+B, A/B)'),
|
||||||
|
displayName: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe('A user-friendly name for display purposes'),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Event with type field for discriminated union
|
||||||
|
export const zChartEventWithType = zChartEvent.extend({
|
||||||
|
type: z.literal('event'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const zChartEventItem = z.discriminatedUnion('type', [
|
||||||
|
zChartEventWithType,
|
||||||
|
zChartFormula,
|
||||||
|
]);
|
||||||
|
|
||||||
export const zChartBreakdown = z.object({
|
export const zChartBreakdown = z.object({
|
||||||
id: z.string().optional(),
|
id: z.string().optional(),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const zChartEvents = z.array(zChartEvent);
|
// Support both old format (array of events without type) and new format (array of event/formula items)
|
||||||
|
// Preprocess to normalize: if item has 'type' field, use discriminated union; otherwise, add type: 'event'
|
||||||
|
export const zChartSeries = z.preprocess((val) => {
|
||||||
|
if (!val) return val;
|
||||||
|
let processedVal = val;
|
||||||
|
|
||||||
|
// If the input is an object with numeric keys, convert it to an array
|
||||||
|
if (typeof val === 'object' && val !== null && !Array.isArray(val)) {
|
||||||
|
const keys = Object.keys(val).sort(
|
||||||
|
(a, b) => Number.parseInt(a) - Number.parseInt(b),
|
||||||
|
);
|
||||||
|
processedVal = keys.map((key) => (val as any)[key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(processedVal)) return processedVal;
|
||||||
|
|
||||||
|
return processedVal.map((item: any) => {
|
||||||
|
// If item already has type field, return as-is
|
||||||
|
if (item && typeof item === 'object' && 'type' in item) {
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
// Otherwise, add type: 'event' for backward compatibility
|
||||||
|
if (item && typeof item === 'object' && 'name' in item) {
|
||||||
|
return { ...item, type: 'event' };
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
}, z
|
||||||
|
.array(zChartEventItem)
|
||||||
|
.describe(
|
||||||
|
'Array of series (events or formulas) to be tracked and displayed in the chart',
|
||||||
|
));
|
||||||
|
|
||||||
|
// Keep zChartEvents as an alias for backward compatibility during migration
|
||||||
|
export const zChartEvents = zChartSeries;
|
||||||
export const zChartBreakdowns = z.array(zChartBreakdown);
|
export const zChartBreakdowns = z.array(zChartBreakdown);
|
||||||
|
|
||||||
export const zChartType = z.enum(objectToZodEnums(chartTypes));
|
export const zChartType = z.enum(objectToZodEnums(chartTypes));
|
||||||
@@ -77,7 +135,7 @@ export const zRange = z.enum(objectToZodEnums(timeWindows));
|
|||||||
|
|
||||||
export const zCriteria = z.enum(['on_or_after', 'on']);
|
export const zCriteria = z.enum(['on_or_after', 'on']);
|
||||||
|
|
||||||
export const zChartInput = z.object({
|
export const zChartInputBase = z.object({
|
||||||
chartType: zChartType
|
chartType: zChartType
|
||||||
.default('linear')
|
.default('linear')
|
||||||
.describe('What type of chart should be displayed'),
|
.describe('What type of chart should be displayed'),
|
||||||
@@ -86,8 +144,8 @@ export const zChartInput = z.object({
|
|||||||
.describe(
|
.describe(
|
||||||
'The time interval for data aggregation (e.g., day, week, month)',
|
'The time interval for data aggregation (e.g., day, week, month)',
|
||||||
),
|
),
|
||||||
events: zChartEvents.describe(
|
series: zChartSeries.describe(
|
||||||
'Array of events to be tracked and displayed in the chart',
|
'Array of series (events or formulas) to be tracked and displayed in the chart',
|
||||||
),
|
),
|
||||||
breakdowns: zChartBreakdowns
|
breakdowns: zChartBreakdowns
|
||||||
.default([])
|
.default([])
|
||||||
@@ -144,7 +202,15 @@ export const zChartInput = z.object({
|
|||||||
.describe('Time window in hours for funnel analysis'),
|
.describe('Time window in hours for funnel analysis'),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const zReportInput = zChartInput.extend({
|
export const zChartInput = z.preprocess((val) => {
|
||||||
|
if (val && typeof val === 'object' && 'events' in val && !('series' in val)) {
|
||||||
|
// Migrate old 'events' field to 'series'
|
||||||
|
return { ...val, series: val.events };
|
||||||
|
}
|
||||||
|
return val;
|
||||||
|
}, zChartInputBase);
|
||||||
|
|
||||||
|
export const zReportInput = zChartInputBase.extend({
|
||||||
name: z.string().describe('The user-defined name for the report'),
|
name: z.string().describe('The user-defined name for the report'),
|
||||||
lineType: zLineType.describe('The visual style of the line in the chart'),
|
lineType: zLineType.describe('The visual style of the line in the chart'),
|
||||||
unit: z
|
unit: z
|
||||||
|
|||||||
28
packages/validation/src/test.ts
Normal file
28
packages/validation/src/test.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { zChartEvents } from '.';
|
||||||
|
|
||||||
|
const events = [
|
||||||
|
{
|
||||||
|
id: 'sAmT',
|
||||||
|
type: 'event',
|
||||||
|
name: 'session_end',
|
||||||
|
segment: 'event',
|
||||||
|
filters: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '5K2v',
|
||||||
|
type: 'event',
|
||||||
|
name: 'session_start',
|
||||||
|
segment: 'event',
|
||||||
|
filters: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lQiQ',
|
||||||
|
type: 'formula',
|
||||||
|
formula: 'A/B',
|
||||||
|
displayName: '',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const res = zChartEvents.safeParse(events);
|
||||||
|
|
||||||
|
console.log(res);
|
||||||
@@ -1,11 +1,18 @@
|
|||||||
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,
|
||||||
|
zChartEventItem,
|
||||||
zChartEventSegment,
|
zChartEventSegment,
|
||||||
|
zChartFormula,
|
||||||
zChartInput,
|
zChartInput,
|
||||||
zChartInputAI,
|
zChartInputAI,
|
||||||
|
zChartSeries,
|
||||||
zChartType,
|
zChartType,
|
||||||
zCriteria,
|
zCriteria,
|
||||||
zLineType,
|
zLineType,
|
||||||
@@ -24,6 +31,11 @@ export type IChartProps = z.infer<typeof zReportInput> & {
|
|||||||
previousIndicatorInverted?: boolean;
|
previousIndicatorInverted?: boolean;
|
||||||
};
|
};
|
||||||
export type IChartEvent = z.infer<typeof zChartEvent>;
|
export type IChartEvent = z.infer<typeof zChartEvent>;
|
||||||
|
export type IChartFormula = z.infer<typeof zChartFormula>;
|
||||||
|
export type IChartEventItem = z.infer<typeof zChartEventItem>;
|
||||||
|
export type IChartSeries = z.infer<typeof zChartSeries>;
|
||||||
|
// Backward compatibility alias
|
||||||
|
export type IChartEvents = IChartSeries;
|
||||||
export type IChartEventSegment = z.infer<typeof zChartEventSegment>;
|
export type IChartEventSegment = z.infer<typeof zChartEventSegment>;
|
||||||
export type IChartEventFilter = IChartEvent['filters'][number];
|
export type IChartEventFilter = IChartEvent['filters'][number];
|
||||||
export type IChartEventFilterValue =
|
export type IChartEventFilterValue =
|
||||||
@@ -45,7 +57,7 @@ export type IGetChartDataInput = {
|
|||||||
projectId: string;
|
projectId: string;
|
||||||
startDate: string;
|
startDate: string;
|
||||||
endDate: string;
|
endDate: string;
|
||||||
} & Omit<IChartInput, 'events' | 'name' | 'startDate' | 'endDate' | 'range'>;
|
} & Omit<IChartInput, 'series' | 'name' | 'startDate' | 'endDate' | 'range'>;
|
||||||
export type ICriteria = z.infer<typeof zCriteria>;
|
export type ICriteria = z.infer<typeof zCriteria>;
|
||||||
|
|
||||||
export type PreviousValue =
|
export type PreviousValue =
|
||||||
@@ -77,6 +89,7 @@ export type IChartSerie = {
|
|||||||
event: {
|
event: {
|
||||||
id?: string;
|
id?: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
breakdowns?: Record<string, string>;
|
||||||
};
|
};
|
||||||
metrics: Metrics;
|
metrics: Metrics;
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
13
pnpm-lock.yaml
generated
13
pnpm-lock.yaml
generated
@@ -629,9 +629,6 @@ importers:
|
|||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^0.476.0
|
specifier: ^0.476.0
|
||||||
version: 0.476.0(react@19.1.1)
|
version: 0.476.0(react@19.1.1)
|
||||||
mathjs:
|
|
||||||
specifier: ^12.3.2
|
|
||||||
version: 12.3.2
|
|
||||||
mitt:
|
mitt:
|
||||||
specifier: ^3.0.1
|
specifier: ^3.0.1
|
||||||
version: 3.0.1
|
version: 3.0.1
|
||||||
@@ -1071,6 +1068,9 @@ importers:
|
|||||||
jiti:
|
jiti:
|
||||||
specifier: ^2.4.1
|
specifier: ^2.4.1
|
||||||
version: 2.4.1
|
version: 2.4.1
|
||||||
|
mathjs:
|
||||||
|
specifier: ^12.3.2
|
||||||
|
version: 12.3.2
|
||||||
prisma-json-types-generator:
|
prisma-json-types-generator:
|
||||||
specifier: ^3.1.1
|
specifier: ^3.1.1
|
||||||
version: 3.1.1(prisma@6.14.0(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3)
|
version: 3.1.1(prisma@6.14.0(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3)
|
||||||
@@ -10317,9 +10317,6 @@ packages:
|
|||||||
decimal.js-light@2.5.1:
|
decimal.js-light@2.5.1:
|
||||||
resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
|
resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
|
||||||
|
|
||||||
decimal.js@10.4.3:
|
|
||||||
resolution: {integrity: sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==}
|
|
||||||
|
|
||||||
decimal.js@10.6.0:
|
decimal.js@10.6.0:
|
||||||
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
|
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
|
||||||
|
|
||||||
@@ -26295,8 +26292,6 @@ snapshots:
|
|||||||
|
|
||||||
decimal.js-light@2.5.1: {}
|
decimal.js-light@2.5.1: {}
|
||||||
|
|
||||||
decimal.js@10.4.3: {}
|
|
||||||
|
|
||||||
decimal.js@10.6.0: {}
|
decimal.js@10.6.0: {}
|
||||||
|
|
||||||
decode-named-character-reference@1.0.2:
|
decode-named-character-reference@1.0.2:
|
||||||
@@ -29314,7 +29309,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@babel/runtime': 7.23.9
|
'@babel/runtime': 7.23.9
|
||||||
complex.js: 2.1.1
|
complex.js: 2.1.1
|
||||||
decimal.js: 10.4.3
|
decimal.js: 10.6.0
|
||||||
escape-latex: 1.2.0
|
escape-latex: 1.2.0
|
||||||
fraction.js: 4.3.4
|
fraction.js: 4.3.4
|
||||||
javascript-natural-sort: 0.7.1
|
javascript-natural-sort: 0.7.1
|
||||||
|
|||||||
Reference in New Issue
Block a user