fix: improve types for chart/reports

This commit is contained in:
Carl-Gerhard Lindesvärd
2026-01-12 16:29:26 +01:00
parent 347a01a941
commit 13bd16b207
33 changed files with 310 additions and 215 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -26,7 +26,6 @@ export const ReportChartShortcut = ({
return ( return (
<ReportChart <ReportChart
report={{ report={{
name: 'Shortcut',
projectId, projectId,
range, range,
breakdowns: breakdowns ?? [], breakdowns: breakdowns ?? [],

View File

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

View File

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

View File

@@ -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" />

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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