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

@@ -1,6 +1,7 @@
import { Markdown } from '@/components/markdown';
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 { Loader2Icon, UserIcon } from 'lucide-react';
import { Fragment, memo } from 'react';
@@ -77,7 +78,10 @@ export const ChatMessage = memo(
const { result } = p.toolInvocation;
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) {
return (
<Fragment key={key}>

View File

@@ -1,6 +1,6 @@
import { pushModal } from '@/modals';
import type {
IChartInputAi,
IReport,
IChartRange,
IChartType,
IInterval,
@@ -16,7 +16,7 @@ import { Button } from '../ui/button';
export function ChatReport({
lazy,
...props
}: { report: IChartInputAi; lazy: boolean }) {
}: { report: IReport & { startDate: string; endDate: string }; lazy: boolean }) {
const [chartType, setChartType] = useState<IChartType>(
props.report.chartType,
);

View File

@@ -1,7 +1,7 @@
import { useEventQueryFilters } from '@/hooks/use-event-query-filters';
import { useMemo, useState } from 'react';
import type { IChartInput } from '@openpanel/validation';
import type { IReportInput } from '@openpanel/validation';
import { useTRPC } from '@/integrations/trpc/react';
import { useQuery } from '@tanstack/react-query';
@@ -74,7 +74,7 @@ export default function OverviewTopEvents({
},
});
const report: IChartInput = useMemo(
const report: IReportInput = useMemo(
() => ({
limit: 1000,
projectId,
@@ -96,9 +96,7 @@ export default function OverviewTopEvents({
},
],
chartType: 'bar' as const,
lineType: 'monotone' as const,
interval,
name: widget.title,
range,
previous,
metric: 'sum' as const,

View File

@@ -11,6 +11,7 @@ import { useQuery } from '@tanstack/react-query';
import { ChevronRightIcon } from 'lucide-react';
import { ReportChart } from '../report-chart';
import { SerieIcon } from '../report-chart/common/serie-icon';
import { ReportChartShortcut } from '../report-chart/shortcut';
import { Widget, WidgetBody } from '../widget';
import { OVERVIEW_COLUMNS_NAME } from './overview-constants';
import OverviewDetailsButton from './overview-details-button';
@@ -210,8 +211,8 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
<div className="title">Map</div>
</WidgetHead>
<WidgetBody>
<ReportChart
report={{
<ReportChartShortcut
{...{
projectId,
startDate,
endDate,
@@ -234,7 +235,6 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
interval: interval,
range: range,
previous: previous,
metric: 'sum',
}}
/>
</WidgetBody>

View File

@@ -2,7 +2,7 @@ import { ReportChart } from '@/components/report-chart';
import { Widget, WidgetBody } from '@/components/widget';
import { memo } from 'react';
import type { IChartProps } from '@openpanel/validation';
import type { IReport } from '@openpanel/validation';
import { WidgetHead } from '../overview/overview-widget';
type Props = {
@@ -12,7 +12,7 @@ type Props = {
export const ProfileCharts = memo(
({ profileId, projectId }: Props) => {
const pageViewsChart: IChartProps = {
const pageViewsChart: IReport = {
projectId,
chartType: 'linear',
series: [
@@ -46,7 +46,7 @@ export const ProfileCharts = memo(
metric: 'sum',
};
const eventsChart: IChartProps = {
const eventsChart: IReport = {
projectId,
chartType: 'linear',
series: [

View File

@@ -2,11 +2,7 @@ import isEqual from 'lodash.isequal';
import type { LucideIcon } from 'lucide-react';
import { createContext, useContext, useEffect, useState } from 'react';
import type {
IChartInput,
IChartProps,
IChartSerie,
} from '@openpanel/validation';
import type { IChartSerie, IReportInput } from '@openpanel/validation';
export type ReportChartContextType = {
options: Partial<{
@@ -27,7 +23,7 @@ export type ReportChartContextType = {
onClick: () => void;
}[];
}>;
report: IChartInput & { id?: string };
report: IReportInput & { id?: string };
isLazyLoading: boolean;
isEditMode: boolean;
shareId?: string;
@@ -39,7 +35,7 @@ type ReportChartContextProviderProps = ReportChartContextType & {
};
export type ReportChartProps = Partial<ReportChartContextType> & {
report: IChartInput;
report: IReportInput & { id?: string };
lazy?: boolean;
};

View File

@@ -131,34 +131,36 @@ export function Tables({
series: reportSeries,
breakdowns: reportBreakdowns,
previous,
funnelWindow,
funnelGroup,
options,
},
} = useReportChartContext();
const funnelOptions = options?.type === 'funnel' ? options : undefined;
const funnelWindow = funnelOptions?.funnelWindow;
const funnelGroup = funnelOptions?.funnelGroup;
const handleInspectStep = (step: (typeof steps)[0], stepIndex: number) => {
if (!projectId || !step.event.id) return;
// For funnels, we need to pass the step index so the modal can query
// users who completed at least that step in the funnel sequence
pushModal('ViewChartUsers', {
type: 'funnel',
report: {
projectId,
series: reportSeries,
breakdowns: reportBreakdowns || [],
interval: interval || 'day',
startDate,
endDate,
range,
previous,
chartType: 'funnel',
metric: 'sum',
funnelWindow,
funnelGroup,
},
stepIndex, // Pass the step index for funnel queries
});
pushModal('ViewChartUsers', {
type: 'funnel',
report: {
projectId,
series: reportSeries,
breakdowns: reportBreakdowns || [],
interval: interval || 'day',
startDate,
endDate,
range,
previous,
chartType: 'funnel',
metric: 'sum',
options: funnelOptions,
},
stepIndex, // Pass the step index for funnel queries
});
};
return (
<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 { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import type { IChartInput } from '@openpanel/validation';
import type { IReportInput } from '@openpanel/validation';
import { AspectContainer } from '../aspect-container';
import { ReportChartEmpty } from '../common/empty';
@@ -19,8 +19,7 @@ export function ReportFunnelChart() {
series,
range,
projectId,
funnelWindow,
funnelGroup,
options,
startDate,
endDate,
previous,
@@ -32,23 +31,22 @@ export function ReportFunnelChart() {
} = useReportChartContext();
const { range: overviewRange, startDate: overviewStartDate, endDate: overviewEndDate, interval: overviewInterval } = useOverviewOptions();
const funnelOptions = options?.type === 'funnel' ? options : undefined;
const trpc = useTRPC();
const input: IChartInput = {
const input: IReportInput = {
series,
range: overviewRange ?? range,
projectId,
interval: overviewInterval ?? interval ?? 'day',
chartType: 'funnel',
breakdowns,
funnelWindow,
funnelGroup,
previous,
metric: 'sum',
startDate: overviewStartDate ?? startDate,
endDate: overviewEndDate ?? endDate,
limit: 20,
shareId,
reportId: id,
options: funnelOptions,
};
const res = useQuery(
trpc.chart.funnel.queryOptions(input, {

View File

@@ -17,20 +17,29 @@ export function ReportRetentionChart() {
series,
range,
projectId,
options,
startDate,
endDate,
criteria,
interval,
},
isLazyLoading,
shareId,
} = 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 firstEvent = (eventSeries[0]?.filters?.[0]?.value ?? []).map(String);
const secondEvent = (eventSeries[1]?.filters?.[0]?.value ?? []).map(String);
const isEnabled =
firstEvent.length > 0 && secondEvent.length > 0 && !isLazyLoading;
const retentionOptions = options?.type === 'retention' ? options : undefined;
const criteria = retentionOptions?.criteria ?? 'on_or_after';
const trpc = useTRPC();
const res = useQuery(
trpc.chart.cohort.queryOptions(

View File

@@ -1,7 +1,7 @@
import { useTRPC } from '@/integrations/trpc/react';
import { useQuery } from '@tanstack/react-query';
import type { IChartInput } from '@openpanel/validation';
import type { IReportInput } from '@openpanel/validation';
import { AspectContainer } from '../aspect-container';
import { ReportChartEmpty } from '../common/empty';
@@ -28,7 +28,7 @@ export function ReportSankeyChart() {
return <Empty />;
}
const input: IChartInput = {
const input: IReportInput = {
series,
range,
projectId,

View File

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

View File

@@ -172,15 +172,13 @@ export function ReportItem({
)}
>
<ReportChart
report={
{
...report,
range: range ?? report.range,
startDate: startDate ?? null,
endDate: endDate ?? null,
interval: interval ?? report.interval,
} as any
}
report={{
...report,
range: range ?? report.range,
startDate: startDate ?? null,
endDate: endDate ?? null,
interval: interval ?? report.interval,
}}
/>
</div>
</div>
@@ -242,7 +240,6 @@ export function ReportItemReadOnly({
)}
>
<ReportChart
type="inputs"
report={{
...report,
range: range ?? report.range,

View File

@@ -12,29 +12,28 @@ import type {
IChartBreakdown,
IChartEventItem,
IChartLineType,
IChartProps,
IChartRange,
IChartType,
IInterval,
IReport,
IReportOptions,
UnionOmit,
zCriteria,
} from '@openpanel/validation';
import type { z } from 'zod';
type InitialState = IChartProps & {
type InitialState = IReport & {
id?: string;
dirty: boolean;
ready: boolean;
startDate: string | null;
endDate: string | null;
options?: IReportOptions;
};
// First approach: define the initial state using that type
const initialState: InitialState = {
ready: false,
dirty: false,
// TODO: remove this
projectId: '',
name: '',
chartType: 'linear',
@@ -50,9 +49,6 @@ const initialState: InitialState = {
unit: undefined,
metric: 'sum',
limit: 500,
criteria: 'on_or_after',
funnelGroup: undefined,
funnelWindow: undefined,
options: undefined,
};
@@ -75,7 +71,7 @@ export const reportSlice = createSlice({
ready: true,
};
},
setReport(state, action: PayloadAction<IChartProps>) {
setReport(state, action: PayloadAction<IReport>) {
return {
...state,
...action.payload,
@@ -265,7 +261,14 @@ export const reportSlice = createSlice({
changeCriteria(state, action: PayloadAction<z.infer<typeof zCriteria>>) {
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>) {
@@ -275,12 +278,28 @@ export const reportSlice = createSlice({
changeFunnelGroup(state, action: PayloadAction<string | undefined>) {
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>) {
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>) {
state.dirty = true;

View File

@@ -332,14 +332,13 @@ export function ReportSeries() {
}}
placeholder="Select event"
items={eventNames}
className="flex-1"
/>
{showFormula && (
<Button
type="button"
variant="outline"
icon={PiIcon}
className="flex-1 justify-start text-left"
className="flex-1 justify-start text-left px-4"
onClick={() => {
dispatch(
addSerie({
@@ -349,7 +348,6 @@ export function ReportSeries() {
}),
);
}}
className="px-4"
>
Add Formula
<PlusIcon className="size-4 ml-auto text-muted-foreground" />

View File

@@ -23,11 +23,15 @@ import {
export function ReportSettings() {
const chartType = useSelector((state) => state.report.chartType);
const previous = useSelector((state) => state.report.previous);
const criteria = useSelector((state) => state.report.criteria);
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 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 { projectId } = useAppParams();

View File

@@ -1,12 +1,12 @@
import { ReportChart } from '@/components/report-chart';
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';
type Props = {
chart: IChartProps;
chart: IReport;
};
const OverviewChartDetails = (props: Props) => {

View File

@@ -10,7 +10,7 @@ import { Controller, useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
import type { IChartProps } from '@openpanel/validation';
import type { IReport } from '@openpanel/validation';
import { Input } from '@/components/ui/input';
import { useTRPC } from '@/integrations/trpc/react';
@@ -21,7 +21,7 @@ import { popModal } from '.';
import { ModalContent, ModalHeader } from './Modal/Container';
type SaveReportProps = {
report: IChartProps;
report: IReport;
disableRedirect?: boolean;
};

View File

@@ -13,7 +13,7 @@ import { useTRPC } from '@/integrations/trpc/react';
import type { IChartData } from '@/trpc/client';
import { cn } from '@/utils/cn';
import { getProfileName } from '@/utils/getters';
import type { IChartInput } from '@openpanel/validation';
import type { IReportInput } from '@openpanel/validation';
import { useQuery } from '@tanstack/react-query';
import { useVirtualizer } from '@tanstack/react-virtual';
import { useEffect, useMemo, useState } from 'react';
@@ -152,7 +152,7 @@ function ProfileList({ profiles }: { profiles: any[] }) {
// Chart-specific props and component
interface ChartUsersViewProps {
chartData: IChartData;
report: IChartInput;
report: IReportInput;
date: string;
}
@@ -279,7 +279,7 @@ function ChartUsersView({ chartData, report, date }: ChartUsersViewProps) {
// Funnel-specific props and component
interface FunnelUsersViewProps {
report: IChartInput;
report: IReportInput;
stepIndex: number;
}
@@ -297,8 +297,14 @@ function FunnelUsersView({ report, stepIndex }: FunnelUsersViewProps) {
series: report.series,
stepIndex: stepIndex,
showDropoffs: showDropoffs,
funnelWindow: report.funnelWindow,
funnelGroup: report.funnelGroup,
funnelWindow:
report.options?.type === 'funnel'
? report.options.funnelWindow
: undefined,
funnelGroup:
report.options?.type === 'funnel'
? report.options.funnelGroup
: undefined,
breakdowns: report.breakdowns,
},
{
@@ -371,12 +377,12 @@ type ViewChartUsersProps =
| {
type: 'chart';
chartData: IChartData;
report: IChartInput;
report: IReportInput;
date: string;
}
| {
type: 'funnel';
report: IChartInput;
report: IReportInput;
stepIndex: number;
};