feat: share dashboard & reports, sankey report, new widgets

* fix: prompt card shadows on light mode

* fix: handle past_due and unpaid from polar

* wip

* wip

* wip 1

* fix: improve types for chart/reports

* wip share
This commit is contained in:
Carl-Gerhard Lindesvärd
2026-01-14 09:21:18 +01:00
committed by GitHub
parent 39251c8598
commit ed1c57dbb8
105 changed files with 6633 additions and 1273 deletions

View File

@@ -1,20 +1,6 @@
import type { NumberFlowProps } from '@number-flow/react';
import { useEffect, useState } from 'react';
import ReactAnimatedNumber from '@number-flow/react';
// NumberFlow is breaking ssr and forces loaders to fetch twice
export function AnimatedNumber(props: NumberFlowProps) {
const [Component, setComponent] =
useState<React.ComponentType<NumberFlowProps> | null>(null);
useEffect(() => {
import('@number-flow/react').then(({ default: NumberFlow }) => {
setComponent(NumberFlow);
});
}, []);
if (!Component) {
return <>{props.value}</>;
}
return <Component {...props} />;
return <ReactAnimatedNumber {...props} />;
}

View File

@@ -8,7 +8,13 @@ import { LogoSquare } from '../logo';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
export function ShareEnterPassword({ shareId }: { shareId: string }) {
export function ShareEnterPassword({
shareId,
shareType = 'overview',
}: {
shareId: string;
shareType?: 'overview' | 'dashboard' | 'report';
}) {
const trpc = useTRPC();
const mutation = useMutation(
trpc.auth.signInShare.mutationOptions({
@@ -25,6 +31,7 @@ export function ShareEnterPassword({ shareId }: { shareId: string }) {
defaultValues: {
password: '',
shareId,
shareType,
},
});
@@ -32,6 +39,7 @@ export function ShareEnterPassword({ shareId }: { shareId: string }) {
mutation.mutate({
password: data.password,
shareId,
shareType,
});
});
@@ -40,9 +48,20 @@ export function ShareEnterPassword({ shareId }: { shareId: string }) {
<div className="bg-background p-6 rounded-lg max-w-md w-full text-left">
<div className="col mt-1 flex-1 gap-2">
<LogoSquare className="size-12 mb-4" />
<div className="text-xl font-semibold">Overview is locked</div>
<div className="text-xl font-semibold">
{shareType === 'dashboard'
? 'Dashboard is locked'
: shareType === 'report'
? 'Report is locked'
: 'Overview is locked'}
</div>
<div className="text-lg text-muted-foreground leading-normal">
Please enter correct password to access this overview
Please enter correct password to access this{' '}
{shareType === 'dashboard'
? 'dashboard'
: shareType === 'report'
? 'report'
: 'overview'}
</div>
</div>
<form onSubmit={onSubmit} className="col gap-4 mt-6">

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

@@ -0,0 +1,95 @@
import type { IServiceReport } from '@openpanel/db';
import { useMemo } from 'react';
import { Responsive, WidthProvider } from 'react-grid-layout';
const ResponsiveGridLayout = WidthProvider(Responsive);
export type Layout = ReactGridLayout.Layout;
export const useReportLayouts = (
reports: NonNullable<IServiceReport>[],
): ReactGridLayout.Layouts => {
return useMemo(() => {
const baseLayout = reports.map((report, index) => ({
i: report.id,
x: report.layout?.x ?? (index % 2) * 6,
y: report.layout?.y ?? Math.floor(index / 2) * 4,
w: report.layout?.w ?? 6,
h: report.layout?.h ?? 4,
minW: 3,
minH: 3,
}));
return {
lg: baseLayout,
md: baseLayout,
sm: baseLayout.map((item) => ({ ...item, w: Math.min(item.w, 6) })),
xs: baseLayout.map((item) => ({ ...item, w: 4, x: 0 })),
xxs: baseLayout.map((item) => ({ ...item, w: 2, x: 0 })),
};
}, [reports]);
};
export function GrafanaGrid({
layouts,
children,
transitions,
onLayoutChange,
onDragStop,
onResizeStop,
isDraggable,
isResizable,
}: {
children: React.ReactNode;
transitions?: boolean;
} & Pick<
ReactGridLayout.ResponsiveProps,
| 'layouts'
| 'onLayoutChange'
| 'onDragStop'
| 'onResizeStop'
| 'isDraggable'
| 'isResizable'
>) {
return (
<>
<style>{`
.react-grid-item {
transition: ${transitions ? 'transform 200ms ease, width 200ms ease, height 200ms ease' : 'none'} !important;
}
.react-grid-item.react-grid-placeholder {
background: none !important;
opacity: 0.5;
transition-duration: 100ms;
border-radius: 0.5rem;
border: 1px dashed var(--primary);
}
.react-grid-item.resizing {
transition: none !important;
}
`}</style>
<div className="-m-4">
<ResponsiveGridLayout
className="layout"
layouts={layouts}
breakpoints={{ lg: 1200, md: 996, sm: 768, xs: 480, xxs: 0 }}
cols={{ lg: 12, md: 12, sm: 6, xs: 4, xxs: 2 }}
rowHeight={100}
draggableHandle=".drag-handle"
compactType="vertical"
preventCollision={false}
margin={[16, 16]}
transformScale={1}
useCSSTransforms={true}
onLayoutChange={onLayoutChange}
onDragStop={onDragStop}
onResizeStop={onResizeStop}
isDraggable={isDraggable}
isResizable={isResizable}
>
{children}
</ResponsiveGridLayout>
</div>
</>
);
}

View File

@@ -33,7 +33,7 @@ export function LoginNavbar({ className }: { className?: string }) {
</a>
</li>
<li>
<a href="https://openpanel.dev/compare/mixpanel-alternative">
<a href="https://openpanel.dev/compare/posthog-alternative">
Posthog alternative
</a>
</li>

View File

@@ -33,7 +33,7 @@ export function PromptCard({
}}
className="fixed bottom-0 right-0 z-50 p-4 max-w-sm"
>
<div className="bg-card border rounded-lg shadow-[0_0_100px_50px_rgba(20,20,20,1)] col gap-6 py-6 overflow-hidden">
<div className="bg-card border rounded-lg shadow-[0_0_100px_50px_var(--color-background)] col gap-6 py-6 overflow-hidden">
<div className="relative px-6 col gap-1">
<div
className="absolute -bottom-10 -right-10 h-64 w-64 rounded-full opacity-30 blur-3xl pointer-events-none"

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,9 +211,8 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
<div className="title">Map</div>
</WidgetHead>
<WidgetBody>
<ReportChart
options={{ hideID: true }}
report={{
<ReportChartShortcut
{...{
projectId,
startDate,
endDate,
@@ -232,12 +232,9 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
},
],
chartType: 'map',
lineType: 'monotone',
interval: interval,
name: 'Top sources',
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

@@ -1,6 +1,7 @@
import { useTRPC } from '@/integrations/trpc/react';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { AspectContainer } from '../aspect-container';
import { ReportChartEmpty } from '../common/empty';
import { ReportChartError } from '../common/error';
@@ -9,15 +10,27 @@ import { useReportChartContext } from '../context';
import { Chart } from './chart';
export function ReportAreaChart() {
const { isLazyLoading, report } = useReportChartContext();
const { isLazyLoading, report, shareId } = useReportChartContext();
const trpc = useTRPC();
const { range, startDate, endDate, interval } = useOverviewOptions();
const res = useQuery(
trpc.chart.chart.queryOptions(report, {
placeholderData: keepPreviousData,
staleTime: 1000 * 60 * 1,
enabled: !isLazyLoading,
}),
trpc.chart.chart.queryOptions(
{
...report,
shareId,
reportId: 'id' in report ? report.id : undefined,
range: range ?? report.range,
startDate: startDate ?? report.startDate,
endDate: endDate ?? report.endDate,
interval: interval ?? report.interval,
},
{
placeholderData: keepPreviousData,
staleTime: 1000 * 60 * 1,
enabled: !isLazyLoading,
},
),
);
if (

View File

@@ -1,6 +1,7 @@
import { useTRPC } from '@/integrations/trpc/react';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { cn } from '@/utils/cn';
import { AspectContainer } from '../aspect-container';
import { ReportChartEmpty } from '../common/empty';
@@ -9,15 +10,27 @@ import { useReportChartContext } from '../context';
import { Chart } from './chart';
export function ReportBarChart() {
const { isLazyLoading, report } = useReportChartContext();
const { isLazyLoading, report, shareId } = useReportChartContext();
const trpc = useTRPC();
const { range, startDate, endDate, interval } = useOverviewOptions();
const res = useQuery(
trpc.chart.aggregate.queryOptions(report, {
placeholderData: keepPreviousData,
staleTime: 1000 * 60 * 1,
enabled: !isLazyLoading,
}),
trpc.chart.aggregate.queryOptions(
{
...report,
shareId,
reportId: 'id' in report ? report.id : undefined,
range: range ?? report.range,
startDate: startDate ?? report.startDate,
endDate: endDate ?? report.endDate,
interval: interval ?? report.interval,
},
{
placeholderData: keepPreviousData,
staleTime: 1000 * 60 * 1,
enabled: !isLazyLoading,
},
),
);
if (

View File

@@ -42,10 +42,10 @@ export function PreviousDiffIndicator({
className,
}: PreviousDiffIndicatorProps) {
const {
report: { previousIndicatorInverted, previous },
report: { previous },
} = useReportChartContext();
const variant = getDiffIndicator(
inverted ?? previousIndicatorInverted,
inverted,
state,
'bg-emerald-300',
'bg-rose-300',

View File

@@ -2,16 +2,11 @@ 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<{
columns: React.ReactNode[];
hideID: boolean;
hideLegend: boolean;
hideXAxis: boolean;
hideYAxis: boolean;
@@ -28,9 +23,11 @@ export type ReportChartContextType = {
onClick: () => void;
}[];
}>;
report: IChartProps;
report: IReportInput & { id?: string };
isLazyLoading: boolean;
isEditMode: boolean;
shareId?: string;
reportId?: string;
};
type ReportChartContextProviderProps = ReportChartContextType & {
@@ -38,7 +35,7 @@ type ReportChartContextProviderProps = ReportChartContextType & {
};
export type ReportChartProps = Partial<ReportChartContextType> & {
report: IChartInput;
report: IReportInput & { id?: string };
lazy?: boolean;
};
@@ -54,20 +51,6 @@ export const useReportChartContext = () => {
return ctx;
};
export const useSelectReportChartContext = <T,>(
selector: (ctx: ReportChartContextType) => T,
) => {
const ctx = useReportChartContext();
const [state, setState] = useState(selector(ctx));
useEffect(() => {
const newState = selector(ctx);
if (!isEqual(newState, state)) {
setState(newState);
}
}, [ctx]);
return state;
};
export const ReportChartProvider = ({
children,
...propsToContext

View File

@@ -1,6 +1,7 @@
import { useTRPC } from '@/integrations/trpc/react';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { cn } from '@/utils/cn';
import { AspectContainer } from '../aspect-container';
import { ReportChartEmpty } from '../common/empty';
@@ -11,15 +12,27 @@ import { Chart } from './chart';
import { Summary } from './summary';
export function ReportConversionChart() {
const { isLazyLoading, report } = useReportChartContext();
const { isLazyLoading, report, shareId } = useReportChartContext();
const trpc = useTRPC();
const { range, startDate, endDate, interval } = useOverviewOptions();
console.log(report.limit);
const res = useQuery(
trpc.chart.conversion.queryOptions(report, {
placeholderData: keepPreviousData,
staleTime: 1000 * 60 * 1,
enabled: !isLazyLoading,
}),
trpc.chart.conversion.queryOptions(
{
...report,
shareId,
reportId: 'id' in report ? report.id : undefined,
range: range ?? report.range,
startDate: startDate ?? report.startDate,
endDate: endDate ?? report.endDate,
interval: interval ?? report.interval,
},
{
placeholderData: keepPreviousData,
staleTime: 1000 * 60 * 1,
enabled: !isLazyLoading,
},
),
);
if (

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

@@ -2,7 +2,8 @@ import { useTRPC } from '@/integrations/trpc/react';
import type { RouterOutputs } from '@/trpc/client';
import { useQuery } from '@tanstack/react-query';
import type { IChartInput } from '@openpanel/validation';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import type { IReportInput } from '@openpanel/validation';
import { AspectContainer } from '../aspect-container';
import { ReportChartEmpty } from '../common/empty';
@@ -14,35 +15,39 @@ import { Chart, Summary, Tables } from './chart';
export function ReportFunnelChart() {
const {
report: {
id,
series,
range,
projectId,
funnelWindow,
funnelGroup,
options,
startDate,
endDate,
previous,
breakdowns,
interval,
},
isLazyLoading,
shareId,
} = useReportChartContext();
const { range: overviewRange, startDate: overviewStartDate, endDate: overviewEndDate, interval: overviewInterval } = useOverviewOptions();
const input: IChartInput = {
const funnelOptions = options?.type === 'funnel' ? options : undefined;
const trpc = useTRPC();
const input: IReportInput = {
series,
range,
range: overviewRange ?? range,
projectId,
interval: 'day',
interval: overviewInterval ?? interval ?? 'day',
chartType: 'funnel',
breakdowns,
funnelWindow,
funnelGroup,
previous,
metric: 'sum',
startDate,
endDate,
startDate: overviewStartDate ?? startDate,
endDate: overviewEndDate ?? endDate,
limit: 20,
options: funnelOptions,
};
const trpc = useTRPC();
const res = useQuery(
trpc.chart.funnel.queryOptions(input, {
enabled: !isLazyLoading && input.series.length > 0,

View File

@@ -1,6 +1,7 @@
import { useTRPC } from '@/integrations/trpc/react';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { AspectContainer } from '../aspect-container';
import { ReportChartEmpty } from '../common/empty';
import { ReportChartError } from '../common/error';
@@ -9,15 +10,27 @@ import { useReportChartContext } from '../context';
import { Chart } from './chart';
export function ReportHistogramChart() {
const { isLazyLoading, report } = useReportChartContext();
const { isLazyLoading, report, shareId } = useReportChartContext();
const trpc = useTRPC();
const { range, startDate, endDate, interval } = useOverviewOptions();
const res = useQuery(
trpc.chart.chart.queryOptions(report, {
placeholderData: keepPreviousData,
staleTime: 1000 * 60 * 1,
enabled: !isLazyLoading,
}),
trpc.chart.chart.queryOptions(
{
...report,
shareId,
reportId: 'id' in report ? report.id : undefined,
range: range ?? report.range,
startDate: startDate ?? report.startDate,
endDate: endDate ?? report.endDate,
interval: interval ?? report.interval,
},
{
placeholderData: keepPreviousData,
staleTime: 1000 * 60 * 1,
enabled: !isLazyLoading,
},
),
);
if (

View File

@@ -15,6 +15,7 @@ import { ReportMapChart } from './map';
import { ReportMetricChart } from './metric';
import { ReportPieChart } from './pie';
import { ReportRetentionChart } from './retention';
import { ReportSankeyChart } from './sankey';
export const ReportChart = ({ lazy = true, ...props }: ReportChartProps) => {
const ref = useRef<HTMLDivElement>(null);
@@ -57,6 +58,8 @@ export const ReportChart = ({ lazy = true, ...props }: ReportChartProps) => {
return <ReportRetentionChart />;
case 'conversion':
return <ReportConversionChart />;
case 'sankey':
return <ReportSankeyChart />;
default:
return null;
}

View File

@@ -2,6 +2,7 @@ import { useTRPC } from '@/integrations/trpc/react';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { cn } from '@/utils/cn';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { AspectContainer } from '../aspect-container';
import { ReportChartEmpty } from '../common/empty';
import { ReportChartError } from '../common/error';
@@ -10,15 +11,27 @@ import { useReportChartContext } from '../context';
import { Chart } from './chart';
export function ReportLineChart() {
const { isLazyLoading, report } = useReportChartContext();
const { isLazyLoading, report, shareId } = useReportChartContext();
const trpc = useTRPC();
const { range, startDate, endDate, interval } = useOverviewOptions();
const res = useQuery(
trpc.chart.chart.queryOptions(report, {
placeholderData: keepPreviousData,
staleTime: 1000 * 60 * 1,
enabled: !isLazyLoading,
}),
trpc.chart.chart.queryOptions(
{
...report,
shareId,
reportId: 'id' in report ? report.id : undefined,
range: range ?? report.range,
startDate: startDate ?? report.startDate,
endDate: endDate ?? report.endDate,
interval: interval ?? report.interval,
},
{
placeholderData: keepPreviousData,
staleTime: 1000 * 60 * 1,
enabled: !isLazyLoading,
},
),
);
if (

View File

@@ -1,6 +1,7 @@
import { useTRPC } from '@/integrations/trpc/react';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { AspectContainer } from '../aspect-container';
import { ReportChartEmpty } from '../common/empty';
import { ReportChartError } from '../common/error';
@@ -9,15 +10,27 @@ import { useReportChartContext } from '../context';
import { Chart } from './chart';
export function ReportMapChart() {
const { isLazyLoading, report } = useReportChartContext();
const { isLazyLoading, report, shareId } = useReportChartContext();
const trpc = useTRPC();
const { range, startDate, endDate, interval } = useOverviewOptions();
const res = useQuery(
trpc.chart.chart.queryOptions(report, {
placeholderData: keepPreviousData,
staleTime: 1000 * 60 * 1,
enabled: !isLazyLoading,
}),
trpc.chart.chart.queryOptions(
{
...report,
shareId,
reportId: 'id' in report ? report.id : undefined,
range: range ?? report.range,
startDate: startDate ?? report.startDate,
endDate: endDate ?? report.endDate,
interval: interval ?? report.interval,
},
{
placeholderData: keepPreviousData,
staleTime: 1000 * 60 * 1,
enabled: !isLazyLoading,
},
),
);
if (

View File

@@ -1,6 +1,7 @@
import { useTRPC } from '@/integrations/trpc/react';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { AspectContainer } from '../aspect-container';
import { ReportChartEmpty } from '../common/empty';
import { ReportChartError } from '../common/error';
@@ -8,15 +9,27 @@ import { useReportChartContext } from '../context';
import { Chart } from './chart';
export function ReportMetricChart() {
const { isLazyLoading, report } = useReportChartContext();
const { isLazyLoading, report, shareId } = useReportChartContext();
const trpc = useTRPC();
const { range, startDate, endDate, interval } = useOverviewOptions();
const res = useQuery(
trpc.chart.chart.queryOptions(report, {
placeholderData: keepPreviousData,
staleTime: 1000 * 60 * 1,
enabled: !isLazyLoading,
}),
trpc.chart.chart.queryOptions(
{
...report,
shareId,
reportId: 'id' in report ? report.id : undefined,
range: range ?? report.range,
startDate: startDate ?? report.startDate,
endDate: endDate ?? report.endDate,
interval: interval ?? report.interval,
},
{
placeholderData: keepPreviousData,
staleTime: 1000 * 60 * 1,
enabled: !isLazyLoading,
},
),
);
if (

View File

@@ -54,10 +54,7 @@ export function MetricCard({
metric,
unit,
}: MetricCardProps) {
const {
report: { previousIndicatorInverted },
isEditMode,
} = useReportChartContext();
const { isEditMode } = useReportChartContext();
const number = useNumber();
const renderValue = (value: number | undefined, unitClassName?: string) => {
@@ -80,7 +77,7 @@ export function MetricCard({
const previous = serie.metrics.previous?.[metric];
const graphColors = getDiffIndicator(
previousIndicatorInverted,
false,
previous?.state,
'#6ee7b7', // green
'#fda4af', // red

View File

@@ -1,6 +1,7 @@
import { useTRPC } from '@/integrations/trpc/react';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { AspectContainer } from '../aspect-container';
import { ReportChartEmpty } from '../common/empty';
import { ReportChartError } from '../common/error';
@@ -9,15 +10,27 @@ import { useReportChartContext } from '../context';
import { Chart } from './chart';
export function ReportPieChart() {
const { isLazyLoading, report } = useReportChartContext();
const { isLazyLoading, report, shareId } = useReportChartContext();
const trpc = useTRPC();
const { range, startDate, endDate, interval } = useOverviewOptions();
const res = useQuery(
trpc.chart.aggregate.queryOptions(report, {
placeholderData: keepPreviousData,
staleTime: 1000 * 60 * 1,
enabled: !isLazyLoading,
}),
trpc.chart.aggregate.queryOptions(
{
...report,
shareId,
reportId: 'id' in report ? report.id : undefined,
range: range ?? report.range,
startDate: startDate ?? report.startDate,
endDate: endDate ?? report.endDate,
interval: interval ?? report.interval,
},
{
placeholderData: keepPreviousData,
staleTime: 1000 * 60 * 1,
enabled: !isLazyLoading,
},
),
);
if (

View File

@@ -11,7 +11,6 @@ import {
changeStartDate,
ready,
reset,
setName,
setReport,
} from '@/components/report/reportSlice';
import { ReportSidebar } from '@/components/report/sidebar/ReportSidebar';
@@ -19,9 +18,10 @@ import { TimeWindowPicker } from '@/components/time-window-picker';
import { Button } from '@/components/ui/button';
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
import { useAppParams } from '@/hooks/use-app-params';
import { pushModal } from '@/modals';
import { useDispatch, useSelector } from '@/redux';
import { bind } from 'bind-event-listener';
import { GanttChartSquareIcon } from 'lucide-react';
import { GanttChartSquareIcon, ShareIcon } from 'lucide-react';
import { useEffect } from 'react';
import type { IServiceReport } from '@openpanel/db';
@@ -54,8 +54,19 @@ export default function ReportEditor({
return (
<Sheet>
<div>
<div className="p-4">
<div className="p-4 flex items-center justify-between">
<EditReportName />
{initialReport?.id && (
<Button
variant="outline"
icon={ShareIcon}
onClick={() =>
pushModal('ShareReportModal', { reportId: initialReport.id })
}
>
Share
</Button>
)}
</div>
<div className="grid grid-cols-2 gap-2 p-4 pt-0 md:grid-cols-6">
<SheetTrigger asChild>

View File

@@ -1,6 +1,7 @@
import { useTRPC } from '@/integrations/trpc/react';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { AspectContainer } from '../aspect-container';
import { ReportChartEmpty } from '../common/empty';
import { ReportChartError } from '../common/error';
@@ -12,21 +13,33 @@ import CohortTable from './table';
export function ReportRetentionChart() {
const {
report: {
id,
series,
range,
projectId,
options,
startDate,
endDate,
criteria,
interval,
},
isLazyLoading,
shareId,
} = useReportChartContext();
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(
@@ -34,11 +47,13 @@ export function ReportRetentionChart() {
firstEvent,
secondEvent,
projectId,
range,
startDate,
endDate,
range: overviewRange ?? range,
startDate: overviewStartDate ?? startDate,
endDate: overviewEndDate ?? endDate,
criteria,
interval,
interval: overviewInterval ?? interval,
shareId,
reportId: id,
},
{
placeholderData: keepPreviousData,

View File

@@ -0,0 +1,302 @@
import {
ChartTooltipContainer,
ChartTooltipHeader,
ChartTooltipItem,
} from '@/components/charts/chart-tooltip';
import { useNumber } from '@/hooks/use-numer-formatter';
import { round } from '@/utils/math';
import { ResponsiveSankey } from '@nivo/sankey';
import {
type ReactNode,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import { createPortal } from 'react-dom';
import { useTheme } from '@/components/theme-provider';
import { truncate } from '@/utils/truncate';
import { ArrowRightIcon } from 'lucide-react';
import { AspectContainer } from '../aspect-container';
type PortalTooltipPosition = { left: number; top: number; ready: boolean };
function SankeyPortalTooltip({
children,
offset = 12,
padding = 8,
}: {
children: ReactNode;
offset?: number;
padding?: number;
}) {
const anchorRef = useRef<HTMLSpanElement | null>(null);
const tooltipRef = useRef<HTMLDivElement | null>(null);
const [anchorRect, setAnchorRect] = useState<DOMRect | null>(null);
const [pos, setPos] = useState<PortalTooltipPosition>({
left: 0,
top: 0,
ready: false,
});
const [mounted, setMounted] = useState(false);
useLayoutEffect(() => {
setMounted(true);
}, []);
useLayoutEffect(() => {
const el = anchorRef.current;
if (!el) return;
const wrapper = el.parentElement;
if (!wrapper) return;
const update = () => {
setAnchorRect(wrapper.getBoundingClientRect());
};
update();
const ro = new ResizeObserver(update);
ro.observe(wrapper);
window.addEventListener('scroll', update, true);
window.addEventListener('resize', update);
return () => {
ro.disconnect();
window.removeEventListener('scroll', update, true);
window.removeEventListener('resize', update);
};
}, []);
useLayoutEffect(() => {
if (!mounted) return;
if (!anchorRect) return;
const tooltipEl = tooltipRef.current;
if (!tooltipEl) return;
const rect = tooltipEl.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
let left = anchorRect.left + offset;
let top = anchorRect.top + offset;
left = Math.min(
Math.max(padding, left),
Math.max(padding, vw - rect.width - padding),
);
top = Math.min(
Math.max(padding, top),
Math.max(padding, vh - rect.height - padding),
);
setPos({ left, top, ready: true });
}, [mounted, anchorRect, children, offset, padding]);
if (typeof document === 'undefined') {
return <>{children}</>;
}
return (
<>
<span ref={anchorRef} className="sr-only" />
{mounted &&
createPortal(
<div
ref={tooltipRef}
className="pointer-events-none fixed z-[9999]"
style={{
left: pos.left,
top: pos.top,
visibility: pos.ready ? 'visible' : 'hidden',
}}
>
{children}
</div>,
document.body,
)}
</>
);
}
type SankeyData = {
nodes: Array<{
id: string;
label: string;
nodeColor: string;
percentage?: number;
value?: number;
step?: number;
}>;
links: Array<{ source: string; target: string; value: number }>;
};
export function Chart({ data }: { data: SankeyData }) {
const number = useNumber();
const containerRef = useRef<HTMLDivElement>(null);
const { appTheme } = useTheme();
// Process data for Sankey
const sankeyData = useMemo(() => {
if (!data) return { nodes: [], links: [] };
return {
nodes: data.nodes.map((node) => ({
...node,
label: node.label || node.id,
data: {
percentage: node.percentage,
value: node.value,
step: node.step,
label: node.label || node.id,
},
})),
links: data.links,
};
}, [data]);
const totalSessions = useMemo(() => {
if (!sankeyData.nodes || sankeyData.nodes.length === 0) return 0;
const step1 = sankeyData.nodes.filter((n: any) => n.data?.step === 1);
const base = step1.length > 0 ? step1 : sankeyData.nodes;
return base.reduce((sum: number, n: any) => sum + (n.data?.value ?? 0), 0);
}, [sankeyData.nodes]);
return (
<AspectContainer>
<div
ref={containerRef}
className="w-full relative aspect-square md:aspect-[2]"
>
<ResponsiveSankey
margin={{ top: 20, right: 20, bottom: 20, left: 20 }}
data={sankeyData}
colors={(node: any) => node.nodeColor}
nodeBorderRadius={2}
animate={false}
nodeBorderWidth={0}
nodeOpacity={0.8}
linkContract={1}
linkOpacity={0.3}
linkBlendMode={'normal'}
nodeTooltip={({ node }: any) => {
const label = node?.data?.label ?? node?.label ?? node?.id;
const value = node?.data?.value ?? node?.value ?? 0;
const step = node?.data?.step;
const pct =
typeof node?.data?.percentage === 'number'
? node.data.percentage
: totalSessions > 0
? (value / totalSessions) * 100
: 0;
const color =
node?.color ??
node?.data?.nodeColor ??
node?.data?.color ??
node?.nodeColor ??
'#64748b';
return (
<SankeyPortalTooltip>
<ChartTooltipContainer className="min-w-[250px]">
<ChartTooltipHeader>
<div className="min-w-0 flex-1 font-medium break-words">
{label}
</div>
{typeof step === 'number' && (
<div className="shrink-0 text-muted-foreground">
Step {step}
</div>
)}
</ChartTooltipHeader>
<ChartTooltipItem color={color} innerClassName="gap-2">
<div className="flex items-center justify-between gap-8 font-mono font-medium">
<div className="text-muted-foreground">Sessions</div>
<div>{number.format(value)}</div>
</div>
<div className="flex items-center justify-between gap-8 font-mono font-medium">
<div className="text-muted-foreground">Share</div>
<div>{number.format(round(pct, 1))} %</div>
</div>
</ChartTooltipItem>
</ChartTooltipContainer>
</SankeyPortalTooltip>
);
}}
linkTooltip={({ link }: any) => {
const sourceLabel =
link?.source?.data?.label ??
link?.source?.label ??
link?.source?.id;
const targetLabel =
link?.target?.data?.label ??
link?.target?.label ??
link?.target?.id;
const value = link?.value ?? 0;
const sourceValue =
link?.source?.data?.value ?? link?.source?.value ?? 0;
const pctOfTotal =
totalSessions > 0 ? (value / totalSessions) * 100 : 0;
const pctOfSource =
sourceValue > 0 ? (value / sourceValue) * 100 : 0;
const sourceStep = link?.source?.data?.step;
const targetStep = link?.target?.data?.step;
const color =
link?.color ??
link?.source?.color ??
link?.source?.data?.nodeColor ??
'#64748b';
return (
<SankeyPortalTooltip>
<ChartTooltipContainer>
<ChartTooltipHeader>
<div className="min-w-0 flex-1 font-medium break-words">
{sourceLabel}
<ArrowRightIcon className="size-2 inline-block mx-3" />
{targetLabel}
</div>
{typeof sourceStep === 'number' &&
typeof targetStep === 'number' && (
<div className="shrink-0 text-muted-foreground">
{sourceStep} {targetStep}
</div>
)}
</ChartTooltipHeader>
<ChartTooltipItem color={color} innerClassName="gap-2">
<div className="flex items-center justify-between gap-8 font-mono font-medium">
<div className="text-muted-foreground">Sessions</div>
<div>{number.format(value)}</div>
</div>
<div className="flex items-center justify-between gap-8 font-mono text-sm">
<div className="text-muted-foreground">% of total</div>
<div>{number.format(round(pctOfTotal, 1))} %</div>
</div>
<div className="flex items-center justify-between gap-8 font-mono text-sm">
<div className="text-muted-foreground">% of source</div>
<div>{number.format(round(pctOfSource, 1))} %</div>
</div>
</ChartTooltipItem>
</ChartTooltipContainer>
</SankeyPortalTooltip>
);
}}
label={(node: any) => {
const label = node.data?.label || node.label || node.id;
return truncate(label, 30, 'middle');
}}
labelTextColor={appTheme === 'dark' ? '#e2e8f0' : '#0f172a'}
nodeSpacing={10}
/>
</div>
</AspectContainer>
);
}

View File

@@ -0,0 +1,93 @@
import { useTRPC } from '@/integrations/trpc/react';
import { useQuery } from '@tanstack/react-query';
import type { IReportInput } from '@openpanel/validation';
import { AspectContainer } from '../aspect-container';
import { ReportChartEmpty } from '../common/empty';
import { ReportChartError } from '../common/error';
import { ReportChartLoading } from '../common/loading';
import { useReportChartContext } from '../context';
import { Chart } from './chart';
export function ReportSankeyChart() {
const {
report: {
series,
range,
projectId,
options,
startDate,
endDate,
breakdowns,
},
isLazyLoading,
} = useReportChartContext();
if (!options) {
return <Empty />;
}
const input: IReportInput = {
series,
range,
projectId,
interval: 'day',
chartType: 'sankey',
breakdowns,
options,
metric: 'sum',
startDate,
endDate,
limit: 20,
previous: false,
};
const trpc = useTRPC();
const res = useQuery(
trpc.chart.sankey.queryOptions(input, {
enabled: !isLazyLoading && input.series.length > 0,
}),
);
if (isLazyLoading || res.isLoading) {
return <Loading />;
}
if (res.isError) {
return <Error />;
}
if (!res.data || res.data.nodes.length === 0) {
return <Empty />;
}
return (
<div className="col gap-4">
<Chart data={res.data} />
</div>
);
}
function Loading() {
return (
<AspectContainer>
<ReportChartLoading />
</AspectContainer>
);
}
function Error() {
return (
<AspectContainer>
<ReportChartError />
</AspectContainer>
);
}
function Empty() {
return (
<AspectContainer>
<ReportChartEmpty />
</AspectContainer>
);
}

View File

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

View File

@@ -5,6 +5,7 @@ import {
ChartColumnIncreasingIcon,
ConeIcon,
GaugeIcon,
GitBranchIcon,
Globe2Icon,
LineChartIcon,
type LucideIcon,
@@ -58,6 +59,7 @@ export function ReportChartType({
retention: UsersIcon,
map: Globe2Icon,
conversion: TrendingUpIcon,
sankey: GitBranchIcon,
};
return (

View File

@@ -0,0 +1,255 @@
import { ReportChart } from '@/components/report-chart';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { cn } from '@/utils/cn';
import { CopyIcon, MoreHorizontal, Trash } from 'lucide-react';
import { timeWindows } from '@openpanel/constants';
import { useRouter } from '@tanstack/react-router';
export function ReportItemSkeleton() {
return (
<div className="card h-full flex flex-col animate-pulse">
<div className="flex items-center justify-between border-b border-border p-4">
<div className="flex-1">
<div className="h-5 w-32 bg-muted rounded mb-2" />
<div className="h-4 w-24 bg-muted/50 rounded" />
</div>
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-muted rounded" />
<div className="w-8 h-8 bg-muted rounded" />
</div>
</div>
<div className="p-4 flex-1 flex items-center justify-center aspect-video" />
</div>
);
}
export function ReportItem({
report,
organizationId,
projectId,
range,
startDate,
endDate,
interval,
onDelete,
onDuplicate,
}: {
report: any;
organizationId: string;
projectId: string;
range: any;
startDate: any;
endDate: any;
interval: any;
onDelete: (reportId: string) => void;
onDuplicate: (reportId: string) => void;
}) {
const router = useRouter();
const chartRange = report.range;
return (
<div className="card h-full flex flex-col">
<div className="flex items-center hover:bg-muted/50 justify-between border-b border-border p-4 leading-none [&_svg]:hover:opacity-100">
<div
className="flex-1 cursor-pointer -m-4 p-4"
onClick={(event) => {
if (event.metaKey) {
window.open(
`/${organizationId}/${projectId}/reports/${report.id}`,
'_blank',
);
return;
}
router.navigate({
to: '/$organizationId/$projectId/reports/$reportId',
params: {
organizationId,
projectId,
reportId: report.id,
},
});
}}
onKeyUp={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
router.navigate({
to: '/$organizationId/$projectId/reports/$reportId',
params: {
organizationId,
projectId,
reportId: report.id,
},
});
}
}}
role="button"
tabIndex={0}
>
<div className="font-medium">{report.name}</div>
{chartRange !== null && (
<div className="mt-2 flex gap-2 ">
<span
className={
(chartRange !== range && range !== null) ||
(startDate && endDate)
? 'line-through'
: ''
}
>
{timeWindows[chartRange as keyof typeof timeWindows]?.label}
</span>
{startDate && endDate ? (
<span>Custom dates</span>
) : (
range !== null &&
chartRange !== range && (
<span>
{timeWindows[range as keyof typeof timeWindows]?.label}
</span>
)
)}
</div>
)}
</div>
<div className="flex items-center gap-2">
<div className="drag-handle cursor-move p-2 hover:bg-muted rounded">
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="currentColor"
className="opacity-30 hover:opacity-100"
>
<circle cx="4" cy="4" r="1.5" />
<circle cx="4" cy="8" r="1.5" />
<circle cx="4" cy="12" r="1.5" />
<circle cx="12" cy="4" r="1.5" />
<circle cx="12" cy="8" r="1.5" />
<circle cx="12" cy="12" r="1.5" />
</svg>
</div>
<DropdownMenu>
<DropdownMenuTrigger className="flex h-8 w-8 items-center justify-center rounded hover:border">
<MoreHorizontal size={16} />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[200px]">
<DropdownMenuItem
onClick={(event) => {
event.stopPropagation();
onDuplicate(report.id);
}}
>
<CopyIcon size={16} className="mr-2" />
Duplicate
</DropdownMenuItem>
<DropdownMenuGroup>
<DropdownMenuItem
className="text-destructive"
onClick={(event) => {
event.stopPropagation();
onDelete(report.id);
}}
>
<Trash size={16} className="mr-2" />
Delete
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<div
className={cn(
'p-4 overflow-auto flex-1',
report.chartType === 'metric' && 'p-0',
)}
>
<ReportChart
report={{
...report,
range: range ?? report.range,
startDate: startDate ?? null,
endDate: endDate ?? null,
interval: interval ?? report.interval,
}}
/>
</div>
</div>
);
}
export function ReportItemReadOnly({
report,
shareId,
range,
startDate,
endDate,
interval,
}: {
report: any;
shareId: string;
range: any;
startDate: any;
endDate: any;
interval: any;
}) {
const chartRange = report.range;
return (
<div className="card h-full flex flex-col">
<div className="flex items-center justify-between border-b border-border p-4 leading-none">
<div className="flex-1">
<div className="font-medium">{report.name}</div>
{chartRange !== null && (
<div className="mt-2 flex gap-2 ">
<span
className={
(chartRange !== range && range !== null) ||
(startDate && endDate)
? 'line-through'
: ''
}
>
{timeWindows[chartRange as keyof typeof timeWindows]?.label}
</span>
{startDate && endDate ? (
<span>Custom dates</span>
) : (
range !== null &&
chartRange !== range && (
<span>
{timeWindows[range as keyof typeof timeWindows]?.label}
</span>
)
)}
</div>
)}
</div>
</div>
<div
className={cn(
'p-4 overflow-auto flex-1',
report.chartType === 'metric' && 'p-0',
)}
>
<ReportChart
report={{
...report,
range: range ?? report.range,
startDate: startDate ?? null,
endDate: endDate ?? null,
interval: interval ?? report.interval,
}}
shareId={shareId}
/>
</div>
</div>
);
}

View File

@@ -1,6 +1,5 @@
import type { PayloadAction } from '@reduxjs/toolkit';
import { createSlice } from '@reduxjs/toolkit';
import { endOfDay, format, isSameDay, isSameMonth, startOfDay } from 'date-fns';
import { shortId } from '@openpanel/common';
import {
@@ -12,18 +11,19 @@ import {
import type {
IChartBreakdown,
IChartEventItem,
IChartFormula,
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;
@@ -34,7 +34,6 @@ type InitialState = IChartProps & {
const initialState: InitialState = {
ready: false,
dirty: false,
// TODO: remove this
projectId: '',
name: '',
chartType: 'linear',
@@ -50,9 +49,7 @@ const initialState: InitialState = {
unit: undefined,
metric: 'sum',
limit: 500,
criteria: 'on_or_after',
funnelGroup: undefined,
funnelWindow: undefined,
options: undefined,
};
export const reportSlice = createSlice({
@@ -74,7 +71,7 @@ export const reportSlice = createSlice({
ready: true,
};
},
setReport(state, action: PayloadAction<IChartProps>) {
setReport(state, action: PayloadAction<IReport>) {
return {
...state,
...action.payload,
@@ -187,6 +184,16 @@ export const reportSlice = createSlice({
state.dirty = true;
state.chartType = action.payload;
// Initialize sankey options if switching to sankey
if (action.payload === 'sankey' && !state.options) {
state.options = {
type: 'sankey',
mode: 'after',
steps: 5,
exclude: [],
};
}
if (
!isMinuteIntervalEnabledByRange(state.range) &&
state.interval === 'minute'
@@ -254,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>) {
@@ -264,12 +278,88 @@ 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;
state.options = action.payload || undefined;
},
changeSankeyMode(
state,
action: PayloadAction<'between' | 'after' | 'before'>,
) {
state.dirty = true;
if (!state.options) {
state.options = {
type: 'sankey',
mode: action.payload,
steps: 5,
exclude: [],
};
} else if (state.options.type === 'sankey') {
state.options.mode = action.payload;
}
},
changeSankeySteps(state, action: PayloadAction<number>) {
state.dirty = true;
if (!state.options) {
state.options = {
type: 'sankey',
mode: 'after',
steps: action.payload,
exclude: [],
};
} else if (state.options.type === 'sankey') {
state.options.steps = action.payload;
}
},
changeSankeyExclude(state, action: PayloadAction<string[]>) {
state.dirty = true;
if (!state.options) {
state.options = {
type: 'sankey',
mode: 'after',
steps: 5,
exclude: action.payload,
};
} else if (state.options.type === 'sankey') {
state.options.exclude = action.payload;
}
},
changeSankeyInclude(state, action: PayloadAction<string[] | undefined>) {
state.dirty = true;
if (!state.options) {
state.options = {
type: 'sankey',
mode: 'after',
steps: 5,
exclude: [],
include: action.payload,
};
} else if (state.options.type === 'sankey') {
state.options.include = action.payload;
}
},
reorderEvents(
state,
@@ -311,6 +401,11 @@ export const {
changeUnit,
changeFunnelGroup,
changeFunnelWindow,
changeOptions,
changeSankeyMode,
changeSankeySteps,
changeSankeyExclude,
changeSankeyInclude,
reorderEvents,
} = reportSlice.actions;

View File

@@ -23,15 +23,13 @@ import {
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { shortId } from '@openpanel/common';
import { alphabetIds } from '@openpanel/constants';
import type {
IChartEvent,
IChartEventItem,
IChartFormula,
} from '@openpanel/validation';
import { FilterIcon, HandIcon, PiIcon } from 'lucide-react';
import { ReportSegment } from '../ReportSegment';
import { HandIcon, PiIcon, PlusIcon } from 'lucide-react';
import {
addSerie,
changeEvent,
@@ -39,27 +37,21 @@ import {
removeEvent,
reorderEvents,
} from '../reportSlice';
import { EventPropertiesCombobox } from './EventPropertiesCombobox';
import { PropertiesCombobox } from './PropertiesCombobox';
import type { ReportEventMoreProps } from './ReportEventMore';
import { ReportEventMore } from './ReportEventMore';
import { FiltersList } from './filters/FiltersList';
import {
ReportSeriesItem,
type ReportSeriesItemProps,
} from './ReportSeriesItem';
function SortableSeries({
function SortableReportSeriesItem({
event,
index,
showSegment,
showAddFilter,
isSelectManyEvents,
...props
}: {
event: IChartEventItem | IChartEvent;
index: number;
showSegment: boolean;
showAddFilter: boolean;
isSelectManyEvents: boolean;
} & React.HTMLAttributes<HTMLDivElement>) {
const dispatch = useDispatch();
}: Omit<ReportSeriesItemProps, 'renderDragHandle'>) {
const eventId = 'type' in event ? event.id : (event as IChartEvent).id;
const { attributes, listeners, setNodeRef, transform, transition } =
useSortable({ id: eventId ?? '' });
@@ -69,85 +61,26 @@ function SortableSeries({
transition,
};
// Normalize event to have type field
const normalizedEvent: IChartEventItem =
'type' in event ? event : { ...event, type: 'event' as const };
const isFormula = normalizedEvent.type === 'formula';
const chartEvent = isFormula
? null
: (normalizedEvent as IChartEventItem & { type: 'event' });
return (
<div ref={setNodeRef} style={style} {...attributes} {...props}>
<div className="flex items-center gap-2 p-2 group">
<button className="cursor-grab active:cursor-grabbing" {...listeners}>
<ColorSquare className="relative">
<HandIcon className="size-3 opacity-0 scale-50 group-hover:opacity-100 group-hover:scale-100 transition-all absolute inset-1" />
<span className="block group-hover:opacity-0 group-hover:scale-0 transition-all">
{alphabetIds[index]}
</span>
</ColorSquare>
</button>
{props.children}
</div>
{/* Segment and Filter buttons - only for events */}
{chartEvent && (showSegment || showAddFilter) && (
<div className="flex gap-2 p-2 pt-0">
{showSegment && (
<ReportSegment
value={chartEvent.segment}
onChange={(segment) => {
dispatch(
changeEvent({
...chartEvent,
segment,
}),
);
}}
/>
)}
{showAddFilter && (
<PropertiesCombobox
event={chartEvent}
onSelect={(action) => {
dispatch(
changeEvent({
...chartEvent,
filters: [
...chartEvent.filters,
{
id: shortId(),
name: action.value,
operator: 'is',
value: [],
},
],
}),
);
}}
>
{(setOpen) => (
<button
onClick={() => setOpen((p) => !p)}
type="button"
className="flex items-center gap-1 rounded-md border border-border bg-card p-1 px-2 text-sm font-medium leading-none"
>
<FilterIcon size={12} /> Add filter
</button>
)}
</PropertiesCombobox>
)}
{showSegment && chartEvent.segment.startsWith('property_') && (
<EventPropertiesCombobox event={chartEvent} />
)}
</div>
)}
{/* Filters - only for events */}
{chartEvent && !isSelectManyEvents && <FiltersList event={chartEvent} />}
<div ref={setNodeRef} style={style} {...attributes}>
<ReportSeriesItem
event={event}
index={index}
showSegment={showSegment}
showAddFilter={showAddFilter}
isSelectManyEvents={isSelectManyEvents}
renderDragHandle={(index) => (
<button className="cursor-grab active:cursor-grabbing" {...listeners}>
<ColorSquare className="relative">
<HandIcon className="size-3 opacity-0 scale-50 group-hover:opacity-100 group-hover:scale-100 transition-all absolute inset-1" />
<span className="block group-hover:opacity-0 group-hover:scale-0 transition-all">
{alphabetIds[index]}
</span>
</ColorSquare>
</button>
)}
{...props}
/>
</div>
);
}
@@ -161,12 +94,23 @@ export function ReportSeries() {
projectId,
});
const showSegment = !['retention', 'funnel'].includes(chartType);
const showAddFilter = !['retention'].includes(chartType);
const showDisplayNameInput = !['retention'].includes(chartType);
const showSegment = !['retention', 'funnel', 'sankey'].includes(chartType);
const showAddFilter = !['retention', 'sankey'].includes(chartType);
const showDisplayNameInput = !['retention', 'sankey'].includes(chartType);
const options = useSelector((state) => state.report.options);
const isSankey = chartType === 'sankey';
const isAddEventDisabled =
(chartType === 'retention' || chartType === 'conversion') &&
selectedSeries.length >= 2;
const isSankeyEventLimitReached =
isSankey &&
options &&
((options.type === 'sankey' &&
options.mode === 'between' &&
selectedSeries.length >= 2) ||
(options.type === 'sankey' &&
options.mode !== 'between' &&
selectedSeries.length >= 1));
const dispatchChangeEvent = useDebounceFn((event: IChartEventItem) => {
dispatch(changeEvent(event));
});
@@ -218,7 +162,8 @@ export function ReportSeries() {
const showFormula =
chartType !== 'conversion' &&
chartType !== 'funnel' &&
chartType !== 'retention';
chartType !== 'retention' &&
chartType !== 'sankey';
return (
<div>
@@ -239,7 +184,7 @@ export function ReportSeries() {
const isFormula = event.type === 'formula';
return (
<SortableSeries
<SortableReportSeriesItem
key={event.id}
event={event}
index={index}
@@ -348,13 +293,14 @@ export function ReportSeries() {
<ReportEventMore onClick={handleMore(event)} />
</>
)}
</SortableSeries>
</SortableReportSeriesItem>
);
})}
<div className="flex gap-2">
<ComboboxEvents
disabled={isAddEventDisabled}
className="flex-1"
disabled={isAddEventDisabled || isSankeyEventLimitReached}
value={''}
searchable
onChange={(value) => {
@@ -386,13 +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 px-4"
onClick={() => {
dispatch(
addSerie({
@@ -402,9 +348,9 @@ export function ReportSeries() {
}),
);
}}
className="px-4"
>
Add Formula
<PlusIcon className="size-4 ml-auto text-muted-foreground" />
</Button>
)}
</div>

View File

@@ -0,0 +1,114 @@
import { ColorSquare } from '@/components/color-square';
import { useDispatch } from '@/redux';
import { shortId } from '@openpanel/common';
import { alphabetIds } from '@openpanel/constants';
import type { IChartEvent, IChartEventItem } from '@openpanel/validation';
import { FilterIcon } from 'lucide-react';
import { ReportSegment } from '../ReportSegment';
import { changeEvent } from '../reportSlice';
import { EventPropertiesCombobox } from './EventPropertiesCombobox';
import { PropertiesCombobox } from './PropertiesCombobox';
import { FiltersList } from './filters/FiltersList';
export interface ReportSeriesItemProps
extends React.HTMLAttributes<HTMLDivElement> {
event: IChartEventItem | IChartEvent;
index: number;
showSegment: boolean;
showAddFilter: boolean;
isSelectManyEvents: boolean;
renderDragHandle?: (index: number) => React.ReactNode;
}
export function ReportSeriesItem({
event,
index,
showSegment,
showAddFilter,
isSelectManyEvents,
renderDragHandle,
...props
}: ReportSeriesItemProps) {
const dispatch = useDispatch();
// Normalize event to have type field
const normalizedEvent: IChartEventItem =
'type' in event ? event : { ...event, type: 'event' as const };
const isFormula = normalizedEvent.type === 'formula';
const chartEvent = isFormula
? null
: (normalizedEvent as IChartEventItem & { type: 'event' });
return (
<div {...props}>
<div className="flex items-center gap-2 p-2 group">
{renderDragHandle ? (
renderDragHandle(index)
) : (
<ColorSquare>
<span className="block">{alphabetIds[index]}</span>
</ColorSquare>
)}
{props.children}
</div>
{/* Segment and Filter buttons - only for events */}
{chartEvent && (showSegment || showAddFilter) && (
<div className="flex gap-2 p-2 pt-0">
{showSegment && (
<ReportSegment
value={chartEvent.segment}
onChange={(segment) => {
dispatch(
changeEvent({
...chartEvent,
segment,
}),
);
}}
/>
)}
{showAddFilter && (
<PropertiesCombobox
event={chartEvent}
onSelect={(action) => {
dispatch(
changeEvent({
...chartEvent,
filters: [
...chartEvent.filters,
{
id: shortId(),
name: action.value,
operator: 'is',
value: [],
},
],
}),
);
}}
>
{(setOpen) => (
<button
onClick={() => setOpen((p) => !p)}
type="button"
className="flex items-center gap-1 rounded-md border border-border bg-card p-1 px-2 text-sm font-medium leading-none"
>
<FilterIcon size={12} /> Add filter
</button>
)}
</PropertiesCombobox>
)}
{showSegment && chartEvent.segment.startsWith('property_') && (
<EventPropertiesCombobox event={chartEvent} />
)}
</div>
)}
{/* Filters - only for events */}
{chartEvent && !isSelectManyEvents && <FiltersList event={chartEvent} />}
</div>
);
}

View File

@@ -1,32 +1,46 @@
import { Combobox } from '@/components/ui/combobox';
import { useDispatch, useSelector } from '@/redux';
import { ComboboxEvents } from '@/components/ui/combobox-events';
import { InputEnter } from '@/components/ui/input-enter';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { useAppParams } from '@/hooks/use-app-params';
import { useEventNames } from '@/hooks/use-event-names';
import { useMemo } from 'react';
import {
changeCriteria,
changeFunnelGroup,
changeFunnelWindow,
changePrevious,
changeSankeyExclude,
changeSankeyInclude,
changeSankeyMode,
changeSankeySteps,
changeUnit,
} from '../reportSlice';
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();
const eventNames = useEventNames({ projectId });
const fields = useMemo(() => {
const fields = [];
if (chartType !== 'retention') {
if (chartType !== 'retention' && chartType !== 'sankey') {
fields.push('previous');
}
@@ -40,6 +54,13 @@ export function ReportSettings() {
fields.push('funnelWindow');
}
if (chartType === 'sankey') {
fields.push('sankeyMode');
fields.push('sankeySteps');
fields.push('sankeyExclude');
fields.push('sankeyInclude');
}
return fields;
}, [chartType]);
@@ -50,7 +71,7 @@ export function ReportSettings() {
return (
<div>
<h3 className="mb-2 font-medium">Settings</h3>
<div className="col rounded-lg border bg-card p-4 gap-2">
<div className="col rounded-lg border bg-card p-4 gap-4">
{fields.includes('previous') && (
<Label className="flex items-center justify-between mb-0">
<span className="whitespace-nowrap">
@@ -64,7 +85,9 @@ export function ReportSettings() {
)}
{fields.includes('criteria') && (
<div className="flex items-center justify-between gap-4">
<span className="whitespace-nowrap font-medium">Criteria</span>
<Label className="whitespace-nowrap font-medium mb-0">
Criteria
</Label>
<Combobox
align="end"
placeholder="Select criteria"
@@ -85,7 +108,7 @@ export function ReportSettings() {
)}
{fields.includes('unit') && (
<div className="flex items-center justify-between gap-4">
<span className="whitespace-nowrap font-medium">Unit</span>
<Label className="whitespace-nowrap font-medium mb-0">Unit</Label>
<Combobox
align="end"
placeholder="Unit"
@@ -108,7 +131,9 @@ export function ReportSettings() {
)}
{fields.includes('funnelGroup') && (
<div className="flex items-center justify-between gap-4">
<span className="whitespace-nowrap font-medium">Funnel Group</span>
<Label className="whitespace-nowrap font-medium mb-0">
Funnel Group
</Label>
<Combobox
align="end"
placeholder="Default: Session"
@@ -133,7 +158,9 @@ export function ReportSettings() {
)}
{fields.includes('funnelWindow') && (
<div className="flex items-center justify-between gap-4">
<span className="whitespace-nowrap font-medium">Funnel Window</span>
<Label className="whitespace-nowrap font-medium mb-0">
Funnel Window
</Label>
<InputEnter
type="number"
value={funnelWindow ? String(funnelWindow) : ''}
@@ -149,6 +176,89 @@ export function ReportSettings() {
/>
</div>
)}
{fields.includes('sankeyMode') && options?.type === 'sankey' && (
<div className="flex items-center justify-between gap-4">
<Label className="whitespace-nowrap font-medium mb-0">Mode</Label>
<Combobox
align="end"
placeholder="Select mode"
value={options?.mode || 'after'}
onChange={(val) => {
dispatch(
changeSankeyMode(val as 'between' | 'after' | 'before'),
);
}}
items={[
{
label: 'After',
value: 'after',
},
{
label: 'Before',
value: 'before',
},
{
label: 'Between',
value: 'between',
},
]}
/>
</div>
)}
{fields.includes('sankeySteps') && options?.type === 'sankey' && (
<div className="flex items-center justify-between gap-4">
<Label className="whitespace-nowrap font-medium mb-0">Steps</Label>
<InputEnter
type="number"
value={options?.steps ? String(options.steps) : '5'}
placeholder="Default: 5"
onChangeValue={(value) => {
const parsed = Number.parseInt(value, 10);
if (Number.isNaN(parsed) || parsed < 2 || parsed > 10) {
dispatch(changeSankeySteps(5));
} else {
dispatch(changeSankeySteps(parsed));
}
}}
/>
</div>
)}
{fields.includes('sankeyExclude') && options?.type === 'sankey' && (
<div className="flex flex-col">
<Label className="whitespace-nowrap font-medium">
Exclude Events
</Label>
<ComboboxEvents
multiple
searchable
value={options?.exclude || []}
onChange={(value) => {
dispatch(changeSankeyExclude(value));
}}
items={eventNames.filter((item) => item.name !== '*')}
placeholder="Select events to exclude"
/>
</div>
)}
{fields.includes('sankeyInclude') && options?.type === 'sankey' && (
<div className="flex flex-col">
<Label className="whitespace-nowrap font-medium">
Include events
</Label>
<ComboboxEvents
multiple
searchable
value={options?.include || []}
onChange={(value) => {
dispatch(
changeSankeyInclude(value.length > 0 ? value : undefined),
);
}}
items={eventNames.filter((item) => item.name !== '*')}
placeholder="Leave empty to include all"
/>
</div>
)}
</div>
</div>
);

View File

@@ -5,14 +5,24 @@ import { useSelector } from '@/redux';
import { ReportBreakdowns } from './ReportBreakdowns';
import { ReportSeries } from './ReportSeries';
import { ReportSettings } from './ReportSettings';
import { ReportFixedEvents } from './report-fixed-events';
export function ReportSidebar() {
const { chartType } = useSelector((state) => state.report);
const showBreakdown = chartType !== 'retention';
const { chartType, options } = useSelector((state) => state.report);
const showBreakdown = chartType !== 'retention' && chartType !== 'sankey';
const showFixedEvents = chartType === 'sankey';
return (
<>
<div className="flex flex-col gap-8">
<ReportSeries />
{showFixedEvents ? (
<ReportFixedEvents
numberOfEvents={
options?.type === 'sankey' && options.mode === 'between' ? 2 : 1
}
/>
) : (
<ReportSeries />
)}
{showBreakdown && <ReportBreakdowns />}
<ReportSettings />
</div>

View File

@@ -0,0 +1,223 @@
import { ColorSquare } from '@/components/color-square';
import { ComboboxEvents } from '@/components/ui/combobox-events';
import { Input } from '@/components/ui/input';
import { InputEnter } from '@/components/ui/input-enter';
import { useAppParams } from '@/hooks/use-app-params';
import { useDebounceFn } from '@/hooks/use-debounce-fn';
import { useEventNames } from '@/hooks/use-event-names';
import { useDispatch, useSelector } from '@/redux';
import { alphabetIds } from '@openpanel/constants';
import type {
IChartEvent,
IChartEventItem,
IChartFormula,
} from '@openpanel/validation';
import {
addSerie,
changeEvent,
duplicateEvent,
removeEvent,
} from '../reportSlice';
import type { ReportEventMoreProps } from './ReportEventMore';
import { ReportEventMore } from './ReportEventMore';
import { ReportSeriesItem } from './ReportSeriesItem';
export function ReportFixedEvents({
numberOfEvents,
}: {
numberOfEvents: number;
}) {
const selectedSeries = useSelector((state) => state.report.series);
const chartType = useSelector((state) => state.report.chartType);
const dispatch = useDispatch();
const { projectId } = useAppParams();
const eventNames = useEventNames({
projectId,
});
const showSegment = !['retention', 'funnel', 'sankey'].includes(chartType);
const showAddFilter = !['retention'].includes(chartType);
const showDisplayNameInput = !['retention', 'sankey'].includes(chartType);
const dispatchChangeEvent = useDebounceFn((event: IChartEventItem) => {
dispatch(changeEvent(event));
});
const isSelectManyEvents = chartType === 'retention';
const handleMore = (event: IChartEventItem | IChartEvent) => {
const callback: ReportEventMoreProps['onClick'] = (action) => {
switch (action) {
case 'remove': {
return dispatch(
removeEvent({
id: 'type' in event ? event.id : (event as IChartEvent).id,
}),
);
}
case 'duplicate': {
const normalized =
'type' in event ? event : { ...event, type: 'event' as const };
return dispatch(duplicateEvent(normalized));
}
}
};
return callback;
};
const dispatchChangeFormula = useDebounceFn((formula: IChartFormula) => {
dispatch(changeEvent(formula));
});
const showFormula =
chartType !== 'conversion' &&
chartType !== 'funnel' &&
chartType !== 'retention' &&
chartType !== 'sankey';
return (
<div>
<h3 className="mb-2 font-medium">Metrics</h3>
<div className="flex flex-col gap-4">
{Array.from({ length: numberOfEvents }, (_, index) => ({
slotId: `fixed-event-slot-${index}`,
index,
})).map(({ slotId, index }) => {
const event = selectedSeries[index];
// If no event exists at this index, render an empty slot
if (!event) {
return (
<div key={slotId} className="rounded-lg border bg-def-100">
<div className="flex items-center gap-2 p-2">
<ColorSquare>
<span className="block">{alphabetIds[index]}</span>
</ColorSquare>
<ComboboxEvents
className="flex-1"
searchable
multiple={isSelectManyEvents as false}
value={''}
onChange={(value) => {
if (isSelectManyEvents) {
dispatch(
addSerie({
type: 'event',
segment: 'user',
name: value,
filters: [
{
name: 'name',
operator: 'is',
value: [value],
},
],
}),
);
} else {
dispatch(
addSerie({
type: 'event',
name: value,
segment: 'event',
filters: [],
}),
);
}
}}
items={eventNames}
placeholder="Select event"
/>
</div>
</div>
);
}
const isFormula = event.type === 'formula';
if (isFormula) {
return null;
}
return (
<ReportSeriesItem
key={event.id}
event={event}
index={index}
showSegment={showSegment}
showAddFilter={showAddFilter}
isSelectManyEvents={isSelectManyEvents}
className="rounded-lg border bg-def-100"
>
<ComboboxEvents
className="flex-1"
searchable
multiple={isSelectManyEvents as false}
value={
(isSelectManyEvents
? ((
event as IChartEventItem & {
type: 'event';
}
).filters[0]?.value ?? [])
: (
event as IChartEventItem & {
type: 'event';
}
).name) as any
}
onChange={(value) => {
dispatch(
changeEvent(
Array.isArray(value)
? {
id: event.id,
type: 'event',
segment: 'user',
filters: [
{
name: 'name',
operator: 'is',
value: value,
},
],
name: '*',
}
: {
...event,
type: 'event',
name: value,
filters: [],
},
),
);
}}
items={eventNames}
placeholder="Select event"
/>
{showDisplayNameInput && (
<Input
placeholder={
(event as IChartEventItem & { type: 'event' }).name
? `${(event as IChartEventItem & { type: 'event' }).name} (${alphabetIds[index]})`
: 'Display name'
}
defaultValue={
(event as IChartEventItem & { type: 'event' }).displayName
}
onChange={(e) => {
dispatchChangeEvent({
...(event as IChartEventItem & {
type: 'event';
}),
displayName: e.target.value,
});
}}
/>
)}
<ReportEventMore onClick={handleMore(event)} />
</ReportSeriesItem>
);
})}
</div>
</div>
);
}

View File

@@ -178,7 +178,7 @@ export function ComboboxEvents<
<CommandEmpty>Nothing selected</CommandEmpty>
<VirtualList
height={400}
height={300}
data={items.filter((item) => {
if (search === '') return true;
return item.name.toLowerCase().includes(search.toLowerCase());

View File

@@ -5,7 +5,7 @@ import type { VariantProps } from 'class-variance-authority';
import * as React from 'react';
const labelVariants = cva(
'mb-3 text-sm block font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
'mb-3 text-sm block font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-foreground/70',
);
const Label = React.forwardRef<