fix: improve types for chart/reports
This commit is contained in:
@@ -13,7 +13,7 @@ import {
|
|||||||
getSettingsForProject,
|
getSettingsForProject,
|
||||||
} from '@openpanel/db';
|
} from '@openpanel/db';
|
||||||
import { ChartEngine } from '@openpanel/db';
|
import { ChartEngine } from '@openpanel/db';
|
||||||
import { zChartEvent, zChartInputBase } from '@openpanel/validation';
|
import { zChartEvent, zReport } 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 = zChartInputBase
|
const chartSchemeFull = zReport
|
||||||
.pick({
|
.pick({
|
||||||
breakdowns: true,
|
breakdowns: true,
|
||||||
interval: true,
|
interval: true,
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
} from '@openpanel/db';
|
} from '@openpanel/db';
|
||||||
import { ChartEngine } from '@openpanel/db';
|
import { ChartEngine } from '@openpanel/db';
|
||||||
import { getCache } from '@openpanel/redis';
|
import { getCache } from '@openpanel/redis';
|
||||||
import { zChartInputAI } from '@openpanel/validation';
|
import { zReportInput } from '@openpanel/validation';
|
||||||
import { tool } from 'ai';
|
import { tool } from 'ai';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
@@ -27,7 +27,10 @@ export function getReport({
|
|||||||
- ${chartTypes.metric}
|
- ${chartTypes.metric}
|
||||||
- ${chartTypes.bar}
|
- ${chartTypes.bar}
|
||||||
`,
|
`,
|
||||||
parameters: zChartInputAI,
|
parameters: zReportInput.extend({
|
||||||
|
startDate: z.string().describe('The start date for the report'),
|
||||||
|
endDate: z.string().describe('The end date for the report'),
|
||||||
|
}),
|
||||||
execute: async (report) => {
|
execute: async (report) => {
|
||||||
return {
|
return {
|
||||||
type: 'report',
|
type: 'report',
|
||||||
@@ -72,7 +75,10 @@ export function getConversionReport({
|
|||||||
return tool({
|
return tool({
|
||||||
description:
|
description:
|
||||||
'Generate a report (a chart) for conversions between two actions a unique user took.',
|
'Generate a report (a chart) for conversions between two actions a unique user took.',
|
||||||
parameters: zChartInputAI,
|
parameters: zReportInput.extend({
|
||||||
|
startDate: z.string().describe('The start date for the report'),
|
||||||
|
endDate: z.string().describe('The end date for the report'),
|
||||||
|
}),
|
||||||
execute: async (report) => {
|
execute: async (report) => {
|
||||||
return {
|
return {
|
||||||
type: 'report',
|
type: 'report',
|
||||||
@@ -94,7 +100,10 @@ export function getFunnelReport({
|
|||||||
return tool({
|
return tool({
|
||||||
description:
|
description:
|
||||||
'Generate a report (a chart) for funnel between two or more actions a unique user (session_id or profile_id) took.',
|
'Generate a report (a chart) for funnel between two or more actions a unique user (session_id or profile_id) took.',
|
||||||
parameters: zChartInputAI,
|
parameters: zReportInput.extend({
|
||||||
|
startDate: z.string().describe('The start date for the report'),
|
||||||
|
endDate: z.string().describe('The end date for the report'),
|
||||||
|
}),
|
||||||
execute: async (report) => {
|
execute: async (report) => {
|
||||||
return {
|
return {
|
||||||
type: 'report',
|
type: 'report',
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Markdown } from '@/components/markdown';
|
import { Markdown } from '@/components/markdown';
|
||||||
import { cn } from '@/utils/cn';
|
import { cn } from '@/utils/cn';
|
||||||
import { zChartInputAI } from '@openpanel/validation';
|
import { zReport } from '@openpanel/validation';
|
||||||
|
import { z } from 'zod';
|
||||||
import type { UIMessage } from 'ai';
|
import type { UIMessage } from 'ai';
|
||||||
import { Loader2Icon, UserIcon } from 'lucide-react';
|
import { Loader2Icon, UserIcon } from 'lucide-react';
|
||||||
import { Fragment, memo } from 'react';
|
import { Fragment, memo } from 'react';
|
||||||
@@ -77,7 +78,10 @@ export const ChatMessage = memo(
|
|||||||
const { result } = p.toolInvocation;
|
const { result } = p.toolInvocation;
|
||||||
|
|
||||||
if (result.type === 'report') {
|
if (result.type === 'report') {
|
||||||
const report = zChartInputAI.safeParse(result.report);
|
const report = zReport.extend({
|
||||||
|
startDate: z.string(),
|
||||||
|
endDate: z.string(),
|
||||||
|
}).safeParse(result.report);
|
||||||
if (report.success) {
|
if (report.success) {
|
||||||
return (
|
return (
|
||||||
<Fragment key={key}>
|
<Fragment key={key}>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { pushModal } from '@/modals';
|
import { pushModal } from '@/modals';
|
||||||
import type {
|
import type {
|
||||||
IChartInputAi,
|
IReport,
|
||||||
IChartRange,
|
IChartRange,
|
||||||
IChartType,
|
IChartType,
|
||||||
IInterval,
|
IInterval,
|
||||||
@@ -16,7 +16,7 @@ import { Button } from '../ui/button';
|
|||||||
export function ChatReport({
|
export function ChatReport({
|
||||||
lazy,
|
lazy,
|
||||||
...props
|
...props
|
||||||
}: { report: IChartInputAi; lazy: boolean }) {
|
}: { report: IReport & { startDate: string; endDate: string }; lazy: boolean }) {
|
||||||
const [chartType, setChartType] = useState<IChartType>(
|
const [chartType, setChartType] = useState<IChartType>(
|
||||||
props.report.chartType,
|
props.report.chartType,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
|
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
import type { IChartInput } from '@openpanel/validation';
|
import type { IReportInput } 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';
|
||||||
@@ -74,7 +74,7 @@ export default function OverviewTopEvents({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const report: IChartInput = useMemo(
|
const report: IReportInput = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
limit: 1000,
|
limit: 1000,
|
||||||
projectId,
|
projectId,
|
||||||
@@ -96,9 +96,7 @@ export default function OverviewTopEvents({
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
chartType: 'bar' as const,
|
chartType: 'bar' as const,
|
||||||
lineType: 'monotone' as const,
|
|
||||||
interval,
|
interval,
|
||||||
name: widget.title,
|
|
||||||
range,
|
range,
|
||||||
previous,
|
previous,
|
||||||
metric: 'sum' as const,
|
metric: 'sum' as const,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { useQuery } from '@tanstack/react-query';
|
|||||||
import { ChevronRightIcon } from 'lucide-react';
|
import { ChevronRightIcon } from 'lucide-react';
|
||||||
import { ReportChart } from '../report-chart';
|
import { ReportChart } from '../report-chart';
|
||||||
import { SerieIcon } from '../report-chart/common/serie-icon';
|
import { SerieIcon } from '../report-chart/common/serie-icon';
|
||||||
|
import { ReportChartShortcut } from '../report-chart/shortcut';
|
||||||
import { Widget, WidgetBody } from '../widget';
|
import { Widget, WidgetBody } from '../widget';
|
||||||
import { OVERVIEW_COLUMNS_NAME } from './overview-constants';
|
import { OVERVIEW_COLUMNS_NAME } from './overview-constants';
|
||||||
import OverviewDetailsButton from './overview-details-button';
|
import OverviewDetailsButton from './overview-details-button';
|
||||||
@@ -210,8 +211,8 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
|
|||||||
<div className="title">Map</div>
|
<div className="title">Map</div>
|
||||||
</WidgetHead>
|
</WidgetHead>
|
||||||
<WidgetBody>
|
<WidgetBody>
|
||||||
<ReportChart
|
<ReportChartShortcut
|
||||||
report={{
|
{...{
|
||||||
projectId,
|
projectId,
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
@@ -234,7 +235,6 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
|
|||||||
interval: interval,
|
interval: interval,
|
||||||
range: range,
|
range: range,
|
||||||
previous: previous,
|
previous: previous,
|
||||||
metric: 'sum',
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</WidgetBody>
|
</WidgetBody>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { ReportChart } from '@/components/report-chart';
|
|||||||
import { Widget, WidgetBody } from '@/components/widget';
|
import { Widget, WidgetBody } from '@/components/widget';
|
||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
|
|
||||||
import type { IChartProps } from '@openpanel/validation';
|
import type { IReport } from '@openpanel/validation';
|
||||||
import { WidgetHead } from '../overview/overview-widget';
|
import { WidgetHead } from '../overview/overview-widget';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -12,7 +12,7 @@ type Props = {
|
|||||||
|
|
||||||
export const ProfileCharts = memo(
|
export const ProfileCharts = memo(
|
||||||
({ profileId, projectId }: Props) => {
|
({ profileId, projectId }: Props) => {
|
||||||
const pageViewsChart: IChartProps = {
|
const pageViewsChart: IReport = {
|
||||||
projectId,
|
projectId,
|
||||||
chartType: 'linear',
|
chartType: 'linear',
|
||||||
series: [
|
series: [
|
||||||
@@ -46,7 +46,7 @@ export const ProfileCharts = memo(
|
|||||||
metric: 'sum',
|
metric: 'sum',
|
||||||
};
|
};
|
||||||
|
|
||||||
const eventsChart: IChartProps = {
|
const eventsChart: IReport = {
|
||||||
projectId,
|
projectId,
|
||||||
chartType: 'linear',
|
chartType: 'linear',
|
||||||
series: [
|
series: [
|
||||||
|
|||||||
@@ -2,11 +2,7 @@ import isEqual from 'lodash.isequal';
|
|||||||
import type { LucideIcon } from 'lucide-react';
|
import type { LucideIcon } from 'lucide-react';
|
||||||
import { createContext, useContext, useEffect, useState } from 'react';
|
import { createContext, useContext, useEffect, useState } from 'react';
|
||||||
|
|
||||||
import type {
|
import type { IChartSerie, IReportInput } from '@openpanel/validation';
|
||||||
IChartInput,
|
|
||||||
IChartProps,
|
|
||||||
IChartSerie,
|
|
||||||
} from '@openpanel/validation';
|
|
||||||
|
|
||||||
export type ReportChartContextType = {
|
export type ReportChartContextType = {
|
||||||
options: Partial<{
|
options: Partial<{
|
||||||
@@ -27,7 +23,7 @@ export type ReportChartContextType = {
|
|||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
}[];
|
}[];
|
||||||
}>;
|
}>;
|
||||||
report: IChartInput & { id?: string };
|
report: IReportInput & { id?: string };
|
||||||
isLazyLoading: boolean;
|
isLazyLoading: boolean;
|
||||||
isEditMode: boolean;
|
isEditMode: boolean;
|
||||||
shareId?: string;
|
shareId?: string;
|
||||||
@@ -39,7 +35,7 @@ type ReportChartContextProviderProps = ReportChartContextType & {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type ReportChartProps = Partial<ReportChartContextType> & {
|
export type ReportChartProps = Partial<ReportChartContextType> & {
|
||||||
report: IChartInput;
|
report: IReportInput & { id?: string };
|
||||||
lazy?: boolean;
|
lazy?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -131,34 +131,36 @@ export function Tables({
|
|||||||
series: reportSeries,
|
series: reportSeries,
|
||||||
breakdowns: reportBreakdowns,
|
breakdowns: reportBreakdowns,
|
||||||
previous,
|
previous,
|
||||||
funnelWindow,
|
options,
|
||||||
funnelGroup,
|
|
||||||
},
|
},
|
||||||
} = useReportChartContext();
|
} = useReportChartContext();
|
||||||
|
|
||||||
|
const funnelOptions = options?.type === 'funnel' ? options : undefined;
|
||||||
|
const funnelWindow = funnelOptions?.funnelWindow;
|
||||||
|
const funnelGroup = funnelOptions?.funnelGroup;
|
||||||
|
|
||||||
const handleInspectStep = (step: (typeof steps)[0], stepIndex: number) => {
|
const handleInspectStep = (step: (typeof steps)[0], stepIndex: number) => {
|
||||||
if (!projectId || !step.event.id) return;
|
if (!projectId || !step.event.id) return;
|
||||||
|
|
||||||
// For funnels, we need to pass the step index so the modal can query
|
// 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
|
// users who completed at least that step in the funnel sequence
|
||||||
pushModal('ViewChartUsers', {
|
pushModal('ViewChartUsers', {
|
||||||
type: 'funnel',
|
type: 'funnel',
|
||||||
report: {
|
report: {
|
||||||
projectId,
|
projectId,
|
||||||
series: reportSeries,
|
series: reportSeries,
|
||||||
breakdowns: reportBreakdowns || [],
|
breakdowns: reportBreakdowns || [],
|
||||||
interval: interval || 'day',
|
interval: interval || 'day',
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
range,
|
range,
|
||||||
previous,
|
previous,
|
||||||
chartType: 'funnel',
|
chartType: 'funnel',
|
||||||
metric: 'sum',
|
metric: 'sum',
|
||||||
funnelWindow,
|
options: funnelOptions,
|
||||||
funnelGroup,
|
},
|
||||||
},
|
stepIndex, // Pass the step index for funnel queries
|
||||||
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')}>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { RouterOutputs } from '@/trpc/client';
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
|
||||||
import type { IChartInput } from '@openpanel/validation';
|
import type { IReportInput } from '@openpanel/validation';
|
||||||
|
|
||||||
import { AspectContainer } from '../aspect-container';
|
import { AspectContainer } from '../aspect-container';
|
||||||
import { ReportChartEmpty } from '../common/empty';
|
import { ReportChartEmpty } from '../common/empty';
|
||||||
@@ -19,8 +19,7 @@ export function ReportFunnelChart() {
|
|||||||
series,
|
series,
|
||||||
range,
|
range,
|
||||||
projectId,
|
projectId,
|
||||||
funnelWindow,
|
options,
|
||||||
funnelGroup,
|
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
previous,
|
previous,
|
||||||
@@ -32,23 +31,22 @@ export function ReportFunnelChart() {
|
|||||||
} = useReportChartContext();
|
} = useReportChartContext();
|
||||||
const { range: overviewRange, startDate: overviewStartDate, endDate: overviewEndDate, interval: overviewInterval } = useOverviewOptions();
|
const { range: overviewRange, startDate: overviewStartDate, endDate: overviewEndDate, interval: overviewInterval } = useOverviewOptions();
|
||||||
|
|
||||||
|
const funnelOptions = options?.type === 'funnel' ? options : undefined;
|
||||||
|
|
||||||
const trpc = useTRPC();
|
const trpc = useTRPC();
|
||||||
const input: IChartInput = {
|
const input: IReportInput = {
|
||||||
series,
|
series,
|
||||||
range: overviewRange ?? range,
|
range: overviewRange ?? range,
|
||||||
projectId,
|
projectId,
|
||||||
interval: overviewInterval ?? interval ?? 'day',
|
interval: overviewInterval ?? interval ?? 'day',
|
||||||
chartType: 'funnel',
|
chartType: 'funnel',
|
||||||
breakdowns,
|
breakdowns,
|
||||||
funnelWindow,
|
|
||||||
funnelGroup,
|
|
||||||
previous,
|
previous,
|
||||||
metric: 'sum',
|
metric: 'sum',
|
||||||
startDate: overviewStartDate ?? startDate,
|
startDate: overviewStartDate ?? startDate,
|
||||||
endDate: overviewEndDate ?? endDate,
|
endDate: overviewEndDate ?? endDate,
|
||||||
limit: 20,
|
limit: 20,
|
||||||
shareId,
|
options: funnelOptions,
|
||||||
reportId: id,
|
|
||||||
};
|
};
|
||||||
const res = useQuery(
|
const res = useQuery(
|
||||||
trpc.chart.funnel.queryOptions(input, {
|
trpc.chart.funnel.queryOptions(input, {
|
||||||
|
|||||||
@@ -17,20 +17,29 @@ export function ReportRetentionChart() {
|
|||||||
series,
|
series,
|
||||||
range,
|
range,
|
||||||
projectId,
|
projectId,
|
||||||
|
options,
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
criteria,
|
|
||||||
interval,
|
interval,
|
||||||
},
|
},
|
||||||
isLazyLoading,
|
isLazyLoading,
|
||||||
shareId,
|
shareId,
|
||||||
} = useReportChartContext();
|
} = useReportChartContext();
|
||||||
const { range: overviewRange, startDate: overviewStartDate, endDate: overviewEndDate, interval: overviewInterval } = useOverviewOptions();
|
const {
|
||||||
|
range: overviewRange,
|
||||||
|
startDate: overviewStartDate,
|
||||||
|
endDate: overviewEndDate,
|
||||||
|
interval: overviewInterval,
|
||||||
|
} = useOverviewOptions();
|
||||||
const eventSeries = series.filter((item) => item.type === 'event');
|
const eventSeries = series.filter((item) => item.type === 'event');
|
||||||
const firstEvent = (eventSeries[0]?.filters?.[0]?.value ?? []).map(String);
|
const firstEvent = (eventSeries[0]?.filters?.[0]?.value ?? []).map(String);
|
||||||
const secondEvent = (eventSeries[1]?.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 retentionOptions = options?.type === 'retention' ? options : undefined;
|
||||||
|
const criteria = retentionOptions?.criteria ?? 'on_or_after';
|
||||||
|
|
||||||
const trpc = useTRPC();
|
const trpc = useTRPC();
|
||||||
const res = useQuery(
|
const res = useQuery(
|
||||||
trpc.chart.cohort.queryOptions(
|
trpc.chart.cohort.queryOptions(
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
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 type { IChartInput } from '@openpanel/validation';
|
import type { IReportInput } from '@openpanel/validation';
|
||||||
|
|
||||||
import { AspectContainer } from '../aspect-container';
|
import { AspectContainer } from '../aspect-container';
|
||||||
import { ReportChartEmpty } from '../common/empty';
|
import { ReportChartEmpty } from '../common/empty';
|
||||||
@@ -28,7 +28,7 @@ export function ReportSankeyChart() {
|
|||||||
return <Empty />;
|
return <Empty />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const input: IChartInput = {
|
const input: IReportInput = {
|
||||||
series,
|
series,
|
||||||
range,
|
range,
|
||||||
projectId,
|
projectId,
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ export const ReportChartShortcut = ({
|
|||||||
return (
|
return (
|
||||||
<ReportChart
|
<ReportChart
|
||||||
report={{
|
report={{
|
||||||
name: 'Shortcut',
|
|
||||||
projectId,
|
projectId,
|
||||||
range,
|
range,
|
||||||
breakdowns: breakdowns ?? [],
|
breakdowns: breakdowns ?? [],
|
||||||
|
|||||||
@@ -172,15 +172,13 @@ export function ReportItem({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<ReportChart
|
<ReportChart
|
||||||
report={
|
report={{
|
||||||
{
|
...report,
|
||||||
...report,
|
range: range ?? report.range,
|
||||||
range: range ?? report.range,
|
startDate: startDate ?? null,
|
||||||
startDate: startDate ?? null,
|
endDate: endDate ?? null,
|
||||||
endDate: endDate ?? null,
|
interval: interval ?? report.interval,
|
||||||
interval: interval ?? report.interval,
|
}}
|
||||||
} as any
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -242,7 +240,6 @@ export function ReportItemReadOnly({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<ReportChart
|
<ReportChart
|
||||||
type="inputs"
|
|
||||||
report={{
|
report={{
|
||||||
...report,
|
...report,
|
||||||
range: range ?? report.range,
|
range: range ?? report.range,
|
||||||
|
|||||||
@@ -12,29 +12,28 @@ import type {
|
|||||||
IChartBreakdown,
|
IChartBreakdown,
|
||||||
IChartEventItem,
|
IChartEventItem,
|
||||||
IChartLineType,
|
IChartLineType,
|
||||||
IChartProps,
|
|
||||||
IChartRange,
|
IChartRange,
|
||||||
IChartType,
|
IChartType,
|
||||||
IInterval,
|
IInterval,
|
||||||
|
IReport,
|
||||||
IReportOptions,
|
IReportOptions,
|
||||||
UnionOmit,
|
UnionOmit,
|
||||||
zCriteria,
|
zCriteria,
|
||||||
} from '@openpanel/validation';
|
} from '@openpanel/validation';
|
||||||
import type { z } from 'zod';
|
import type { z } from 'zod';
|
||||||
|
|
||||||
type InitialState = IChartProps & {
|
type InitialState = IReport & {
|
||||||
|
id?: string;
|
||||||
dirty: boolean;
|
dirty: boolean;
|
||||||
ready: boolean;
|
ready: boolean;
|
||||||
startDate: string | null;
|
startDate: string | null;
|
||||||
endDate: string | null;
|
endDate: string | null;
|
||||||
options?: IReportOptions;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// First approach: define the initial state using that type
|
// First approach: define the initial state using that type
|
||||||
const initialState: InitialState = {
|
const initialState: InitialState = {
|
||||||
ready: false,
|
ready: false,
|
||||||
dirty: false,
|
dirty: false,
|
||||||
// TODO: remove this
|
|
||||||
projectId: '',
|
projectId: '',
|
||||||
name: '',
|
name: '',
|
||||||
chartType: 'linear',
|
chartType: 'linear',
|
||||||
@@ -50,9 +49,6 @@ const initialState: InitialState = {
|
|||||||
unit: undefined,
|
unit: undefined,
|
||||||
metric: 'sum',
|
metric: 'sum',
|
||||||
limit: 500,
|
limit: 500,
|
||||||
criteria: 'on_or_after',
|
|
||||||
funnelGroup: undefined,
|
|
||||||
funnelWindow: undefined,
|
|
||||||
options: undefined,
|
options: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -75,7 +71,7 @@ export const reportSlice = createSlice({
|
|||||||
ready: true,
|
ready: true,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
setReport(state, action: PayloadAction<IChartProps>) {
|
setReport(state, action: PayloadAction<IReport>) {
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
...action.payload,
|
...action.payload,
|
||||||
@@ -265,7 +261,14 @@ export const reportSlice = createSlice({
|
|||||||
|
|
||||||
changeCriteria(state, action: PayloadAction<z.infer<typeof zCriteria>>) {
|
changeCriteria(state, action: PayloadAction<z.infer<typeof zCriteria>>) {
|
||||||
state.dirty = true;
|
state.dirty = true;
|
||||||
state.criteria = action.payload;
|
if (!state.options || state.options.type !== 'retention') {
|
||||||
|
state.options = {
|
||||||
|
type: 'retention',
|
||||||
|
criteria: action.payload,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
state.options.criteria = action.payload;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
changeUnit(state, action: PayloadAction<string | undefined>) {
|
changeUnit(state, action: PayloadAction<string | undefined>) {
|
||||||
@@ -275,12 +278,28 @@ export const reportSlice = createSlice({
|
|||||||
|
|
||||||
changeFunnelGroup(state, action: PayloadAction<string | undefined>) {
|
changeFunnelGroup(state, action: PayloadAction<string | undefined>) {
|
||||||
state.dirty = true;
|
state.dirty = true;
|
||||||
state.funnelGroup = action.payload || undefined;
|
if (!state.options || state.options.type !== 'funnel') {
|
||||||
|
state.options = {
|
||||||
|
type: 'funnel',
|
||||||
|
funnelGroup: action.payload,
|
||||||
|
funnelWindow: undefined,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
state.options.funnelGroup = action.payload;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
changeFunnelWindow(state, action: PayloadAction<number | undefined>) {
|
changeFunnelWindow(state, action: PayloadAction<number | undefined>) {
|
||||||
state.dirty = true;
|
state.dirty = true;
|
||||||
state.funnelWindow = action.payload || undefined;
|
if (!state.options || state.options.type !== 'funnel') {
|
||||||
|
state.options = {
|
||||||
|
type: 'funnel',
|
||||||
|
funnelGroup: undefined,
|
||||||
|
funnelWindow: action.payload,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
state.options.funnelWindow = action.payload;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
changeOptions(state, action: PayloadAction<IReportOptions | undefined>) {
|
changeOptions(state, action: PayloadAction<IReportOptions | undefined>) {
|
||||||
state.dirty = true;
|
state.dirty = true;
|
||||||
|
|||||||
@@ -332,14 +332,13 @@ export function ReportSeries() {
|
|||||||
}}
|
}}
|
||||||
placeholder="Select event"
|
placeholder="Select event"
|
||||||
items={eventNames}
|
items={eventNames}
|
||||||
className="flex-1"
|
|
||||||
/>
|
/>
|
||||||
{showFormula && (
|
{showFormula && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
icon={PiIcon}
|
icon={PiIcon}
|
||||||
className="flex-1 justify-start text-left"
|
className="flex-1 justify-start text-left px-4"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
dispatch(
|
dispatch(
|
||||||
addSerie({
|
addSerie({
|
||||||
@@ -349,7 +348,6 @@ export function ReportSeries() {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
className="px-4"
|
|
||||||
>
|
>
|
||||||
Add Formula
|
Add Formula
|
||||||
<PlusIcon className="size-4 ml-auto text-muted-foreground" />
|
<PlusIcon className="size-4 ml-auto text-muted-foreground" />
|
||||||
|
|||||||
@@ -23,11 +23,15 @@ import {
|
|||||||
export function ReportSettings() {
|
export function ReportSettings() {
|
||||||
const chartType = useSelector((state) => state.report.chartType);
|
const chartType = useSelector((state) => state.report.chartType);
|
||||||
const previous = useSelector((state) => state.report.previous);
|
const previous = useSelector((state) => state.report.previous);
|
||||||
const criteria = useSelector((state) => state.report.criteria);
|
|
||||||
const unit = useSelector((state) => state.report.unit);
|
const unit = useSelector((state) => state.report.unit);
|
||||||
const funnelGroup = useSelector((state) => state.report.funnelGroup);
|
|
||||||
const funnelWindow = useSelector((state) => state.report.funnelWindow);
|
|
||||||
const options = useSelector((state) => state.report.options);
|
const options = useSelector((state) => state.report.options);
|
||||||
|
|
||||||
|
const retentionOptions = options?.type === 'retention' ? options : undefined;
|
||||||
|
const criteria = retentionOptions?.criteria ?? 'on_or_after';
|
||||||
|
|
||||||
|
const funnelOptions = options?.type === 'funnel' ? options : undefined;
|
||||||
|
const funnelGroup = funnelOptions?.funnelGroup;
|
||||||
|
const funnelWindow = funnelOptions?.funnelWindow;
|
||||||
|
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const { projectId } = useAppParams();
|
const { projectId } = useAppParams();
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { ReportChart } from '@/components/report-chart';
|
import { ReportChart } from '@/components/report-chart';
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
|
||||||
import type { IChartProps } from '@openpanel/validation';
|
import type { IReport } from '@openpanel/validation';
|
||||||
|
|
||||||
import { ModalContent, ModalHeader } from './Modal/Container';
|
import { ModalContent, ModalHeader } from './Modal/Container';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
chart: IChartProps;
|
chart: IReport;
|
||||||
};
|
};
|
||||||
|
|
||||||
const OverviewChartDetails = (props: Props) => {
|
const OverviewChartDetails = (props: Props) => {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { Controller, useForm } from 'react-hook-form';
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import type { IChartProps } from '@openpanel/validation';
|
import type { IReport } from '@openpanel/validation';
|
||||||
|
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { useTRPC } from '@/integrations/trpc/react';
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
@@ -21,7 +21,7 @@ import { popModal } from '.';
|
|||||||
import { ModalContent, ModalHeader } from './Modal/Container';
|
import { ModalContent, ModalHeader } from './Modal/Container';
|
||||||
|
|
||||||
type SaveReportProps = {
|
type SaveReportProps = {
|
||||||
report: IChartProps;
|
report: IReport;
|
||||||
disableRedirect?: boolean;
|
disableRedirect?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { useTRPC } from '@/integrations/trpc/react';
|
|||||||
import type { IChartData } from '@/trpc/client';
|
import type { IChartData } from '@/trpc/client';
|
||||||
import { cn } from '@/utils/cn';
|
import { cn } from '@/utils/cn';
|
||||||
import { getProfileName } from '@/utils/getters';
|
import { getProfileName } from '@/utils/getters';
|
||||||
import type { IChartInput } from '@openpanel/validation';
|
import type { IReportInput } from '@openpanel/validation';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
@@ -152,7 +152,7 @@ function ProfileList({ profiles }: { profiles: any[] }) {
|
|||||||
// Chart-specific props and component
|
// Chart-specific props and component
|
||||||
interface ChartUsersViewProps {
|
interface ChartUsersViewProps {
|
||||||
chartData: IChartData;
|
chartData: IChartData;
|
||||||
report: IChartInput;
|
report: IReportInput;
|
||||||
date: string;
|
date: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,7 +279,7 @@ function ChartUsersView({ chartData, report, date }: ChartUsersViewProps) {
|
|||||||
|
|
||||||
// Funnel-specific props and component
|
// Funnel-specific props and component
|
||||||
interface FunnelUsersViewProps {
|
interface FunnelUsersViewProps {
|
||||||
report: IChartInput;
|
report: IReportInput;
|
||||||
stepIndex: number;
|
stepIndex: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -297,8 +297,14 @@ function FunnelUsersView({ report, stepIndex }: FunnelUsersViewProps) {
|
|||||||
series: report.series,
|
series: report.series,
|
||||||
stepIndex: stepIndex,
|
stepIndex: stepIndex,
|
||||||
showDropoffs: showDropoffs,
|
showDropoffs: showDropoffs,
|
||||||
funnelWindow: report.funnelWindow,
|
funnelWindow:
|
||||||
funnelGroup: report.funnelGroup,
|
report.options?.type === 'funnel'
|
||||||
|
? report.options.funnelWindow
|
||||||
|
: undefined,
|
||||||
|
funnelGroup:
|
||||||
|
report.options?.type === 'funnel'
|
||||||
|
? report.options.funnelGroup
|
||||||
|
: undefined,
|
||||||
breakdowns: report.breakdowns,
|
breakdowns: report.breakdowns,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -371,12 +377,12 @@ type ViewChartUsersProps =
|
|||||||
| {
|
| {
|
||||||
type: 'chart';
|
type: 'chart';
|
||||||
chartData: IChartData;
|
chartData: IChartData;
|
||||||
report: IChartInput;
|
report: IReportInput;
|
||||||
date: string;
|
date: string;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: 'funnel';
|
type: 'funnel';
|
||||||
report: IChartInput;
|
report: IReportInput;
|
||||||
stepIndex: number;
|
stepIndex: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
88
packages/db/code-migrations/9-migrate-options.ts
Normal file
88
packages/db/code-migrations/9-migrate-options.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import type { IReportOptions } from '@openpanel/validation';
|
||||||
|
import { db } from '../index';
|
||||||
|
import { printBoxMessage } from './helpers';
|
||||||
|
|
||||||
|
export async function up() {
|
||||||
|
printBoxMessage('🔄 Migrating Legacy Fields to Options', []);
|
||||||
|
|
||||||
|
// Get all reports
|
||||||
|
const reports = await db.report.findMany({
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
chartType: true,
|
||||||
|
funnelGroup: true,
|
||||||
|
funnelWindow: true,
|
||||||
|
criteria: true,
|
||||||
|
options: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let migratedCount = 0;
|
||||||
|
let skippedCount = 0;
|
||||||
|
|
||||||
|
for (const report of reports) {
|
||||||
|
const currentOptions = report.options as IReportOptions | null | undefined;
|
||||||
|
|
||||||
|
// Skip if options already exists and is valid
|
||||||
|
if (currentOptions && typeof currentOptions === 'object' && 'type' in currentOptions) {
|
||||||
|
skippedCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let newOptions: IReportOptions | null = null;
|
||||||
|
|
||||||
|
// Migrate based on chart type
|
||||||
|
if (report.chartType === 'funnel') {
|
||||||
|
// Only create options if we have legacy fields to migrate
|
||||||
|
if (report.funnelGroup || report.funnelWindow !== null) {
|
||||||
|
newOptions = {
|
||||||
|
type: 'funnel',
|
||||||
|
funnelGroup: report.funnelGroup ?? undefined,
|
||||||
|
funnelWindow: report.funnelWindow ?? undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else if (report.chartType === 'retention') {
|
||||||
|
// Only create options if we have criteria to migrate
|
||||||
|
if (report.criteria) {
|
||||||
|
newOptions = {
|
||||||
|
type: 'retention',
|
||||||
|
criteria: report.criteria as 'on_or_after' | 'on' | undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else if (report.chartType === 'sankey') {
|
||||||
|
// Sankey should already have options, but if not, skip
|
||||||
|
skippedCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only update if we have new options to set
|
||||||
|
if (newOptions) {
|
||||||
|
console.log(
|
||||||
|
`Migrating report ${report.name} (${report.id}) - chartType: ${report.chartType}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
await db.report.update({
|
||||||
|
where: { id: report.id },
|
||||||
|
data: {
|
||||||
|
options: newOptions,
|
||||||
|
// Set legacy fields to null after migration
|
||||||
|
funnelGroup: null,
|
||||||
|
funnelWindow: null,
|
||||||
|
criteria: report.chartType === 'retention' ? null : report.criteria,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
migratedCount++;
|
||||||
|
} else {
|
||||||
|
skippedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
printBoxMessage('✅ Migration Complete', [
|
||||||
|
`Migrated: ${migratedCount} reports`,
|
||||||
|
`Skipped: ${skippedCount} reports (already migrated or no legacy fields)`,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -53,9 +53,6 @@ export async function fetch(plan: Plan): Promise<ConcreteSeries[]> {
|
|||||||
previous: plan.input.previous ?? false,
|
previous: plan.input.previous ?? false,
|
||||||
limit: plan.input.limit,
|
limit: plan.input.limit,
|
||||||
offset: plan.input.offset,
|
offset: plan.input.offset,
|
||||||
criteria: plan.input.criteria,
|
|
||||||
funnelGroup: plan.input.funnelGroup,
|
|
||||||
funnelWindow: plan.input.funnelWindow,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Execute query
|
// Execute query
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { alphabetIds } from '@openpanel/constants';
|
|||||||
import type {
|
import type {
|
||||||
FinalChart,
|
FinalChart,
|
||||||
IChartEventItem,
|
IChartEventItem,
|
||||||
IChartInput,
|
IReportInput,
|
||||||
} from '@openpanel/validation';
|
} from '@openpanel/validation';
|
||||||
import { chQuery } from '../clickhouse/client';
|
import { chQuery } from '../clickhouse/client';
|
||||||
import {
|
import {
|
||||||
@@ -26,7 +26,7 @@ import type { ConcreteSeries } from './types';
|
|||||||
* Chart Engine - Main entry point
|
* Chart Engine - Main entry point
|
||||||
* Executes the pipeline: normalize -> plan -> fetch -> compute -> format
|
* Executes the pipeline: normalize -> plan -> fetch -> compute -> format
|
||||||
*/
|
*/
|
||||||
export async function executeChart(input: IChartInput): Promise<FinalChart> {
|
export async function executeChart(input: IReportInput): Promise<FinalChart> {
|
||||||
// Stage 1: Normalize input
|
// Stage 1: Normalize input
|
||||||
const normalized = await normalize(input);
|
const normalized = await normalize(input);
|
||||||
|
|
||||||
@@ -83,7 +83,7 @@ export async function executeChart(input: IChartInput): Promise<FinalChart> {
|
|||||||
* Executes a simplified pipeline: normalize -> fetch aggregate -> format
|
* Executes a simplified pipeline: normalize -> fetch aggregate -> format
|
||||||
*/
|
*/
|
||||||
export async function executeAggregateChart(
|
export async function executeAggregateChart(
|
||||||
input: IChartInput,
|
input: IReportInput,
|
||||||
): Promise<FinalChart> {
|
): Promise<FinalChart> {
|
||||||
// Stage 1: Normalize input
|
// Stage 1: Normalize input
|
||||||
const normalized = await normalize(input);
|
const normalized = await normalize(input);
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import { alphabetIds } from '@openpanel/constants';
|
|||||||
import type {
|
import type {
|
||||||
IChartEvent,
|
IChartEvent,
|
||||||
IChartEventItem,
|
IChartEventItem,
|
||||||
IChartInput,
|
IReportInput,
|
||||||
IChartInputWithDates,
|
IReportInputWithDates,
|
||||||
} from '@openpanel/validation';
|
} from '@openpanel/validation';
|
||||||
import { getChartStartEndDate } from '../services/chart.service';
|
import { getChartStartEndDate } from '../services/chart.service';
|
||||||
import { getSettingsForProject } from '../services/organization.service';
|
import { getSettingsForProject } from '../services/organization.service';
|
||||||
@@ -15,8 +15,8 @@ export type NormalizedInput = Awaited<ReturnType<typeof normalize>>;
|
|||||||
* Normalize a chart input into a clean structure with dates and normalized series
|
* Normalize a chart input into a clean structure with dates and normalized series
|
||||||
*/
|
*/
|
||||||
export async function normalize(
|
export async function normalize(
|
||||||
input: IChartInput,
|
input: IReportInput,
|
||||||
): Promise<IChartInputWithDates & { series: SeriesDefinition[] }> {
|
): Promise<IReportInputWithDates & { series: SeriesDefinition[] }> {
|
||||||
const { timezone } = await getSettingsForProject(input.projectId);
|
const { timezone } = await getSettingsForProject(input.projectId);
|
||||||
const { startDate, endDate } = getChartStartEndDate(
|
const { startDate, endDate } = getChartStartEndDate(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import type {
|
|||||||
IChartEventFilter,
|
IChartEventFilter,
|
||||||
IChartEventItem,
|
IChartEventItem,
|
||||||
IChartFormula,
|
IChartFormula,
|
||||||
IChartInput,
|
IReportInput,
|
||||||
IChartInputWithDates,
|
IReportInputWithDates,
|
||||||
} from '@openpanel/validation';
|
} from '@openpanel/validation';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -50,7 +50,7 @@ export type ConcreteSeries = {
|
|||||||
export type Plan = {
|
export type Plan = {
|
||||||
concreteSeries: ConcreteSeries[];
|
concreteSeries: ConcreteSeries[];
|
||||||
definitions: SeriesDefinition[];
|
definitions: SeriesDefinition[];
|
||||||
input: IChartInputWithDates;
|
input: IReportInputWithDates;
|
||||||
timezone: string;
|
timezone: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import sqlstring from 'sqlstring';
|
|||||||
import { DateTime, stripLeadingAndTrailingSlashes } from '@openpanel/common';
|
import { DateTime, stripLeadingAndTrailingSlashes } from '@openpanel/common';
|
||||||
import type {
|
import type {
|
||||||
IChartEventFilter,
|
IChartEventFilter,
|
||||||
IChartInput,
|
IReportInput,
|
||||||
IChartRange,
|
IChartRange,
|
||||||
IGetChartDataInput,
|
IGetChartDataInput,
|
||||||
} from '@openpanel/validation';
|
} from '@openpanel/validation';
|
||||||
@@ -973,7 +973,7 @@ export function getChartStartEndDate(
|
|||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
range,
|
range,
|
||||||
}: Pick<IChartInput, 'endDate' | 'startDate' | 'range'>,
|
}: Pick<IReportInput, 'endDate' | 'startDate' | 'range'>,
|
||||||
timezone: string,
|
timezone: string,
|
||||||
) {
|
) {
|
||||||
if (startDate && endDate) {
|
if (startDate && endDate) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NOT_SET_VALUE } from '@openpanel/constants';
|
import { NOT_SET_VALUE } from '@openpanel/constants';
|
||||||
import type { IChartEvent, IChartInput } from '@openpanel/validation';
|
import type { IChartEvent, IChartBreakdown, IReportInput } 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';
|
||||||
@@ -16,21 +16,23 @@ export class ConversionService {
|
|||||||
projectId,
|
projectId,
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
funnelGroup,
|
options,
|
||||||
funnelWindow = 24,
|
|
||||||
series,
|
series,
|
||||||
breakdowns = [],
|
breakdowns = [],
|
||||||
limit,
|
limit,
|
||||||
interval,
|
interval,
|
||||||
timezone,
|
timezone,
|
||||||
}: Omit<IChartInput, 'range' | 'previous' | 'metric' | 'chartType'> & {
|
}: Omit<IReportInput, 'range' | 'previous' | 'metric' | 'chartType'> & {
|
||||||
timezone: string;
|
timezone: string;
|
||||||
}) {
|
}) {
|
||||||
|
const funnelOptions = options?.type === 'funnel' ? options : undefined;
|
||||||
|
const funnelGroup = funnelOptions?.funnelGroup;
|
||||||
|
const funnelWindow = funnelOptions?.funnelWindow ?? 24;
|
||||||
const group = funnelGroup === 'profile_id' ? 'profile_id' : 'session_id';
|
const group = funnelGroup === 'profile_id' ? 'profile_id' : 'session_id';
|
||||||
const breakdownColumns = breakdowns.map(
|
const breakdownColumns = breakdowns.map(
|
||||||
(b, index) => `${getSelectPropertyKey(b.name)} as b_${index}`,
|
(b: IChartBreakdown, index: number) => `${getSelectPropertyKey(b.name)} as b_${index}`,
|
||||||
);
|
);
|
||||||
const breakdownGroupBy = breakdowns.map((b, index) => `b_${index}`);
|
const breakdownGroupBy = breakdowns.map((b: IChartBreakdown, index: number) => `b_${index}`);
|
||||||
|
|
||||||
const events = onlyReportEvents(series);
|
const events = onlyReportEvents(series);
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { ifNaN } from '@openpanel/common';
|
|||||||
import type {
|
import type {
|
||||||
IChartEvent,
|
IChartEvent,
|
||||||
IChartEventItem,
|
IChartEventItem,
|
||||||
IChartInput,
|
IReportInput,
|
||||||
} from '@openpanel/validation';
|
} from '@openpanel/validation';
|
||||||
import { last, reverse, uniq } from 'ramda';
|
import { last, reverse, uniq } from 'ramda';
|
||||||
import sqlstring from 'sqlstring';
|
import sqlstring from 'sqlstring';
|
||||||
@@ -185,16 +185,19 @@ export class FunnelService {
|
|||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
series,
|
series,
|
||||||
funnelWindow = 24,
|
options,
|
||||||
funnelGroup,
|
|
||||||
breakdowns = [],
|
breakdowns = [],
|
||||||
limit,
|
limit,
|
||||||
timezone = 'UTC',
|
timezone = 'UTC',
|
||||||
}: IChartInput & { timezone: string; events?: IChartEvent[] }) {
|
}: IReportInput & { 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');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const funnelOptions = options?.type === 'funnel' ? options : undefined;
|
||||||
|
const funnelWindow = funnelOptions?.funnelWindow ?? 24;
|
||||||
|
const funnelGroup = funnelOptions?.funnelGroup;
|
||||||
|
|
||||||
const eventSeries = onlyReportEvents(series);
|
const eventSeries = onlyReportEvents(series);
|
||||||
|
|
||||||
if (eventSeries.length === 0) {
|
if (eventSeries.length === 0) {
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import type {
|
|||||||
IChartEventFilter,
|
IChartEventFilter,
|
||||||
IChartEventItem,
|
IChartEventItem,
|
||||||
IChartLineType,
|
IChartLineType,
|
||||||
IChartProps,
|
|
||||||
IChartRange,
|
IChartRange,
|
||||||
|
IReport,
|
||||||
IReportOptions,
|
IReportOptions,
|
||||||
} from '@openpanel/validation';
|
} from '@openpanel/validation';
|
||||||
|
|
||||||
@@ -65,23 +65,22 @@ export function transformReportEventItem(
|
|||||||
|
|
||||||
export function transformReport(
|
export function transformReport(
|
||||||
report: DbReport & { layout?: ReportLayout | null },
|
report: DbReport & { layout?: ReportLayout | null },
|
||||||
): IChartProps & {
|
): IReport & {
|
||||||
id: string;
|
id: string;
|
||||||
layout?: ReportLayout | null;
|
layout?: ReportLayout | null;
|
||||||
} {
|
} {
|
||||||
// Parse options from JSON field, fallback to legacy fields for backward compatibility
|
|
||||||
const options = report.options as IReportOptions | null | undefined;
|
const options = report.options as IReportOptions | null | undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: report.id,
|
id: report.id,
|
||||||
projectId: report.projectId,
|
projectId: report.projectId,
|
||||||
series:
|
name: report.name || 'Untitled',
|
||||||
(report.events as IChartEventItem[]).map(transformReportEventItem) ?? [],
|
|
||||||
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,
|
||||||
interval: report.interval,
|
interval: report.interval,
|
||||||
name: report.name || 'Untitled',
|
series:
|
||||||
|
(report.events as IChartEventItem[]).map(transformReportEventItem) ?? [],
|
||||||
|
breakdowns: report.breakdowns as IChartBreakdown[],
|
||||||
range:
|
range:
|
||||||
report.range in deprecated_timeRanges
|
report.range in deprecated_timeRanges
|
||||||
? '30d'
|
? '30d'
|
||||||
@@ -90,15 +89,8 @@ export function transformReport(
|
|||||||
formula: report.formula ?? undefined,
|
formula: report.formula ?? undefined,
|
||||||
metric: report.metric ?? 'sum',
|
metric: report.metric ?? 'sum',
|
||||||
unit: report.unit ?? undefined,
|
unit: report.unit ?? undefined,
|
||||||
criteria: (report.criteria ?? 'on_or_after') as
|
|
||||||
| 'on_or_after'
|
|
||||||
| 'on'
|
|
||||||
| undefined,
|
|
||||||
layout: report.layout ?? undefined,
|
layout: report.layout ?? undefined,
|
||||||
options: options ?? undefined,
|
options: options ?? undefined,
|
||||||
// Depercated, just for frontend backward compatibility (will be removed)
|
|
||||||
funnelGroup: report.funnelGroup ?? undefined,
|
|
||||||
funnelWindow: report.funnelWindow ?? undefined,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import {
|
|||||||
} from '@openpanel/db';
|
} from '@openpanel/db';
|
||||||
import {
|
import {
|
||||||
type IChartEvent,
|
type IChartEvent,
|
||||||
zChartInput,
|
zReportInput,
|
||||||
zChartSeries,
|
zChartSeries,
|
||||||
zCriteria,
|
zCriteria,
|
||||||
zRange,
|
zRange,
|
||||||
@@ -335,7 +335,7 @@ export const chartRouter = createTRPCRouter({
|
|||||||
|
|
||||||
funnel: publicProcedure
|
funnel: publicProcedure
|
||||||
.input(
|
.input(
|
||||||
zChartInput.and(
|
zReportInput.and(
|
||||||
z.object({
|
z.object({
|
||||||
shareId: z.string().optional(),
|
shareId: z.string().optional(),
|
||||||
reportId: z.string().optional(),
|
reportId: z.string().optional(),
|
||||||
@@ -417,7 +417,7 @@ export const chartRouter = createTRPCRouter({
|
|||||||
|
|
||||||
conversion: publicProcedure
|
conversion: publicProcedure
|
||||||
.input(
|
.input(
|
||||||
zChartInput.and(
|
zReportInput.and(
|
||||||
z.object({
|
z.object({
|
||||||
shareId: z.string().optional(),
|
shareId: z.string().optional(),
|
||||||
reportId: z.string().optional(),
|
reportId: z.string().optional(),
|
||||||
@@ -511,7 +511,7 @@ export const chartRouter = createTRPCRouter({
|
|||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
sankey: protectedProcedure.input(zChartInput).query(async ({ input }) => {
|
sankey: protectedProcedure.input(zReportInput).query(async ({ input }) => {
|
||||||
const { timezone } = await getSettingsForProject(input.projectId);
|
const { timezone } = await getSettingsForProject(input.projectId);
|
||||||
const currentPeriod = getChartStartEndDate(input, timezone);
|
const currentPeriod = getChartStartEndDate(input, timezone);
|
||||||
|
|
||||||
@@ -546,7 +546,7 @@ export const chartRouter = createTRPCRouter({
|
|||||||
chart: publicProcedure
|
chart: publicProcedure
|
||||||
// .use(cacher)
|
// .use(cacher)
|
||||||
.input(
|
.input(
|
||||||
zChartInput.and(
|
zReportInput.and(
|
||||||
z.object({
|
z.object({
|
||||||
shareId: z.string().optional(),
|
shareId: z.string().optional(),
|
||||||
reportId: z.string().optional(),
|
reportId: z.string().optional(),
|
||||||
@@ -606,7 +606,7 @@ export const chartRouter = createTRPCRouter({
|
|||||||
|
|
||||||
aggregate: publicProcedure
|
aggregate: publicProcedure
|
||||||
.input(
|
.input(
|
||||||
zChartInput.and(
|
zReportInput.and(
|
||||||
z.object({
|
z.object({
|
||||||
shareId: z.string().optional(),
|
shareId: z.string().optional(),
|
||||||
reportId: z.string().optional(),
|
reportId: z.string().optional(),
|
||||||
@@ -721,7 +721,8 @@ export const chartRouter = createTRPCRouter({
|
|||||||
}
|
}
|
||||||
|
|
||||||
projectId = report.projectId;
|
projectId = report.projectId;
|
||||||
criteria = report.criteria ?? criteria;
|
const retentionOptions = report.options?.type === 'retention' ? report.options : undefined;
|
||||||
|
criteria = retentionOptions?.criteria ?? criteria;
|
||||||
dateRange = input.range ?? report.range;
|
dateRange = input.range ?? report.range;
|
||||||
startDate = input.startDate ?? report.startDate;
|
startDate = input.startDate ?? report.startDate;
|
||||||
endDate = input.endDate ?? report.endDate;
|
endDate = input.endDate ?? report.endDate;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { db, getReportById, getReportsByDashboardId } from '@openpanel/db';
|
import { db, getReportById, getReportsByDashboardId } from '@openpanel/db';
|
||||||
import { zReportInput } from '@openpanel/validation';
|
import { zReport } from '@openpanel/validation';
|
||||||
|
|
||||||
import { getProjectAccess } from '../access';
|
import { getProjectAccess } from '../access';
|
||||||
import { TRPCAccessError } from '../errors';
|
import { TRPCAccessError } from '../errors';
|
||||||
@@ -21,7 +21,7 @@ export const reportRouter = createTRPCRouter({
|
|||||||
create: protectedProcedure
|
create: protectedProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
report: zReportInput.omit({ projectId: true }),
|
report: zReport.omit({ projectId: true }),
|
||||||
dashboardId: z.string(),
|
dashboardId: z.string(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -55,10 +55,7 @@ export const reportRouter = createTRPCRouter({
|
|||||||
formula: report.formula,
|
formula: report.formula,
|
||||||
previous: report.previous ?? false,
|
previous: report.previous ?? false,
|
||||||
unit: report.unit,
|
unit: report.unit,
|
||||||
criteria: report.criteria,
|
|
||||||
metric: report.metric === 'count' ? 'sum' : report.metric,
|
metric: report.metric === 'count' ? 'sum' : report.metric,
|
||||||
funnelGroup: report.funnelGroup,
|
|
||||||
funnelWindow: report.funnelWindow,
|
|
||||||
options: report.options,
|
options: report.options,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -67,7 +64,7 @@ export const reportRouter = createTRPCRouter({
|
|||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
reportId: z.string(),
|
reportId: z.string(),
|
||||||
report: zReportInput.omit({ projectId: true }),
|
report: zReport.omit({ projectId: true }),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.mutation(async ({ input: { report, reportId }, ctx }) => {
|
.mutation(async ({ input: { report, reportId }, ctx }) => {
|
||||||
@@ -101,10 +98,7 @@ export const reportRouter = createTRPCRouter({
|
|||||||
formula: report.formula,
|
formula: report.formula,
|
||||||
previous: report.previous ?? false,
|
previous: report.previous ?? false,
|
||||||
unit: report.unit,
|
unit: report.unit,
|
||||||
criteria: report.criteria,
|
|
||||||
metric: report.metric === 'count' ? 'sum' : report.metric,
|
metric: report.metric === 'count' ? 'sum' : report.metric,
|
||||||
funnelGroup: report.funnelGroup,
|
|
||||||
funnelWindow: report.funnelWindow,
|
|
||||||
options: report.options,
|
options: report.options,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -173,10 +167,7 @@ export const reportRouter = createTRPCRouter({
|
|||||||
formula: report.formula,
|
formula: report.formula,
|
||||||
previous: report.previous,
|
previous: report.previous,
|
||||||
unit: report.unit,
|
unit: report.unit,
|
||||||
criteria: report.criteria,
|
|
||||||
metric: report.metric,
|
metric: report.metric,
|
||||||
funnelGroup: report.funnelGroup,
|
|
||||||
funnelWindow: report.funnelWindow,
|
|
||||||
options: report.options,
|
options: report.options,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -135,7 +135,9 @@ export const zReportOptions = z.discriminatedUnion('type', [
|
|||||||
export type IReportOptions = z.infer<typeof zReportOptions>;
|
export type IReportOptions = z.infer<typeof zReportOptions>;
|
||||||
export type ISankeyOptions = z.infer<typeof zSankeyOptions>;
|
export type ISankeyOptions = z.infer<typeof zSankeyOptions>;
|
||||||
|
|
||||||
export const zChartInputBase = z.object({
|
// Base input schema - for API calls, engine, chart queries
|
||||||
|
export const zReportInput = z.object({
|
||||||
|
projectId: z.string().describe('The ID of the project this chart belongs to'),
|
||||||
chartType: zChartType
|
chartType: zChartType
|
||||||
.default('linear')
|
.default('linear')
|
||||||
.describe('What type of chart should be displayed'),
|
.describe('What type of chart should be displayed'),
|
||||||
@@ -153,6 +155,18 @@ export const zChartInputBase = z.object({
|
|||||||
range: zRange
|
range: zRange
|
||||||
.default('30d')
|
.default('30d')
|
||||||
.describe('The time range for which data should be displayed'),
|
.describe('The time range for which data should be displayed'),
|
||||||
|
startDate: z
|
||||||
|
.string()
|
||||||
|
.nullish()
|
||||||
|
.describe(
|
||||||
|
'Custom start date for the data range (overrides range if provided)',
|
||||||
|
),
|
||||||
|
endDate: z
|
||||||
|
.string()
|
||||||
|
.nullish()
|
||||||
|
.describe(
|
||||||
|
'Custom end date for the data range (overrides range if provided)',
|
||||||
|
),
|
||||||
previous: z
|
previous: z
|
||||||
.boolean()
|
.boolean()
|
||||||
.default(false)
|
.default(false)
|
||||||
@@ -166,19 +180,6 @@ export const zChartInputBase = z.object({
|
|||||||
.describe(
|
.describe(
|
||||||
'The aggregation method for the metric (e.g., sum, count, average)',
|
'The aggregation method for the metric (e.g., sum, count, average)',
|
||||||
),
|
),
|
||||||
projectId: z.string().describe('The ID of the project this chart belongs to'),
|
|
||||||
startDate: z
|
|
||||||
.string()
|
|
||||||
.nullish()
|
|
||||||
.describe(
|
|
||||||
'Custom start date for the data range (overrides range if provided)',
|
|
||||||
),
|
|
||||||
endDate: z
|
|
||||||
.string()
|
|
||||||
.nullish()
|
|
||||||
.describe(
|
|
||||||
'Custom end date for the data range (overrides range if provided)',
|
|
||||||
),
|
|
||||||
limit: z
|
limit: z
|
||||||
.number()
|
.number()
|
||||||
.optional()
|
.optional()
|
||||||
@@ -187,27 +188,12 @@ export const zChartInputBase = z.object({
|
|||||||
.number()
|
.number()
|
||||||
.optional()
|
.optional()
|
||||||
.describe('Skip how many series should be returned'),
|
.describe('Skip how many series should be returned'),
|
||||||
criteria: zCriteria
|
options: zReportOptions
|
||||||
.optional()
|
.optional()
|
||||||
.describe('Filtering criteria for retention chart (e.g., on_or_after, on)'),
|
.describe('Chart-specific options (funnel, retention, sankey)'),
|
||||||
funnelGroup: z
|
// Optional display fields
|
||||||
.string()
|
name: z.string().optional().describe('The user-defined name for the report'),
|
||||||
.optional()
|
lineType: zLineType.optional().describe('The visual style of the line in the chart'),
|
||||||
.describe(
|
|
||||||
'Group identifier for funnel analysis, e.g. "profile_id" or "session_id"',
|
|
||||||
),
|
|
||||||
funnelWindow: z
|
|
||||||
.number()
|
|
||||||
.optional()
|
|
||||||
.describe('Time window in hours for funnel analysis'),
|
|
||||||
options: zReportOptions.optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const zChartInput = zChartInputBase;
|
|
||||||
|
|
||||||
export const zReportInput = zChartInputBase.extend({
|
|
||||||
name: z.string().describe('The user-defined name for the report'),
|
|
||||||
lineType: zLineType.describe('The visual style of the line in the chart'),
|
|
||||||
unit: z
|
unit: z
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
@@ -216,17 +202,14 @@ export const zReportInput = zChartInputBase.extend({
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const zChartInputAI = zReportInput
|
// Complete report schema - for saved reports
|
||||||
.omit({
|
export const zReport = zReportInput.extend({
|
||||||
startDate: true,
|
name: z.string().default('Untitled').describe('The user-defined name for the report'),
|
||||||
endDate: true,
|
lineType: zLineType.default('monotone').describe('The visual style of the line in the chart'),
|
||||||
lineType: true,
|
});
|
||||||
unit: true,
|
|
||||||
})
|
// Alias for backward compatibility
|
||||||
.extend({
|
export const zChartInput = zReportInput;
|
||||||
startDate: z.string().describe('The start date for the report'),
|
|
||||||
endDate: z.string().describe('The end date for the report'),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const zInviteUser = z.object({
|
export const zInviteUser = z.object({
|
||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
|
|||||||
@@ -10,25 +10,28 @@ import type {
|
|||||||
zChartEventItem,
|
zChartEventItem,
|
||||||
zChartEventSegment,
|
zChartEventSegment,
|
||||||
zChartFormula,
|
zChartFormula,
|
||||||
zChartInput,
|
|
||||||
zChartInputAI,
|
|
||||||
zChartSeries,
|
zChartSeries,
|
||||||
zChartType,
|
zChartType,
|
||||||
zCriteria,
|
zCriteria,
|
||||||
zLineType,
|
zLineType,
|
||||||
zMetric,
|
zMetric,
|
||||||
zRange,
|
zRange,
|
||||||
|
zReport,
|
||||||
zReportInput,
|
zReportInput,
|
||||||
zTimeInterval,
|
zTimeInterval,
|
||||||
} from './index';
|
} from './index';
|
||||||
|
|
||||||
export type IChartInput = z.infer<typeof zChartInput>;
|
// For saved reports - complete report with required display fields
|
||||||
export type IChartInputAi = z.infer<typeof zChartInputAI>;
|
export type IReport = z.infer<typeof zReport>;
|
||||||
export type IChartProps = z.infer<typeof zReportInput> & {
|
|
||||||
name: string;
|
// For API/engine use - flexible input
|
||||||
lineType: IChartLineType;
|
export type IReportInput = z.infer<typeof zReportInput>;
|
||||||
unit?: string;
|
|
||||||
};
|
// With resolved dates (engine internal)
|
||||||
|
export interface IReportInputWithDates extends IReportInput {
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
}
|
||||||
export type IChartEvent = z.infer<typeof zChartEvent>;
|
export type IChartEvent = z.infer<typeof zChartEvent>;
|
||||||
export type IChartFormula = z.infer<typeof zChartFormula>;
|
export type IChartFormula = z.infer<typeof zChartFormula>;
|
||||||
export type IChartEventItem = z.infer<typeof zChartEventItem>;
|
export type IChartEventItem = z.infer<typeof zChartEventItem>;
|
||||||
@@ -47,16 +50,12 @@ export type IChartType = z.infer<typeof zChartType>;
|
|||||||
export type IChartMetric = z.infer<typeof zMetric>;
|
export type IChartMetric = z.infer<typeof zMetric>;
|
||||||
export type IChartLineType = z.infer<typeof zLineType>;
|
export type IChartLineType = z.infer<typeof zLineType>;
|
||||||
export type IChartRange = z.infer<typeof zRange>;
|
export type IChartRange = z.infer<typeof zRange>;
|
||||||
export interface IChartInputWithDates extends IChartInput {
|
|
||||||
startDate: string;
|
|
||||||
endDate: string;
|
|
||||||
}
|
|
||||||
export type IGetChartDataInput = {
|
export type IGetChartDataInput = {
|
||||||
event: IChartEvent;
|
event: IChartEvent;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
startDate: string;
|
startDate: string;
|
||||||
endDate: string;
|
endDate: string;
|
||||||
} & Omit<IChartInput, 'series' | 'name' | 'startDate' | 'endDate' | 'range'>;
|
} & Omit<IReportInput, 'series' | 'startDate' | 'endDate' | 'range'>;
|
||||||
export type ICriteria = z.infer<typeof zCriteria>;
|
export type ICriteria = z.infer<typeof zCriteria>;
|
||||||
|
|
||||||
export type PreviousValue =
|
export type PreviousValue =
|
||||||
|
|||||||
Reference in New Issue
Block a user