feature(dashboard): add new retention chart type
This commit is contained in:
committed by
Carl-Gerhard Lindesvärd
parent
e2065da16e
commit
f977c5454a
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -22,7 +22,8 @@
|
|||||||
"editor.formatOnSave": true,
|
"editor.formatOnSave": true,
|
||||||
"tailwindCSS.experimental.classRegex": [
|
"tailwindCSS.experimental.classRegex": [
|
||||||
["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"],
|
["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"],
|
||||||
["cx\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"]
|
["cx\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"],
|
||||||
|
["\\b\\w+ClassName\\s*=\\s*[\"'`]([^\"'`]*)[\"'`]", "[\"'`]([^\"'`]*)[\"'`]" ]
|
||||||
],
|
],
|
||||||
"typescript.enablePromptUseWorkspaceTsdk": true,
|
"typescript.enablePromptUseWorkspaceTsdk": true,
|
||||||
"typescript.tsdk": "node_modules/typescript/lib",
|
"typescript.tsdk": "node_modules/typescript/lib",
|
||||||
|
|||||||
@@ -14,6 +14,6 @@ export function isBot(ua: string) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
name: res.name,
|
name: res.name,
|
||||||
type: res.category || 'Unknown',
|
type: 'category' in res ? res.category : 'Unknown',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,8 +18,8 @@
|
|||||||
"@openpanel/common": "workspace:^",
|
"@openpanel/common": "workspace:^",
|
||||||
"@openpanel/constants": "workspace:^",
|
"@openpanel/constants": "workspace:^",
|
||||||
"@openpanel/db": "workspace:^",
|
"@openpanel/db": "workspace:^",
|
||||||
"@openpanel/nextjs": "1.0.3",
|
|
||||||
"@openpanel/integrations": "workspace:^",
|
"@openpanel/integrations": "workspace:^",
|
||||||
|
"@openpanel/nextjs": "1.0.3",
|
||||||
"@openpanel/queue": "workspace:^",
|
"@openpanel/queue": "workspace:^",
|
||||||
"@openpanel/sdk-info": "workspace:^",
|
"@openpanel/sdk-info": "workspace:^",
|
||||||
"@openpanel/validation": "workspace:^",
|
"@openpanel/validation": "workspace:^",
|
||||||
@@ -72,7 +72,7 @@
|
|||||||
"lodash.isequal": "^4.5.0",
|
"lodash.isequal": "^4.5.0",
|
||||||
"lodash.throttle": "^4.1.1",
|
"lodash.throttle": "^4.1.1",
|
||||||
"lottie-react": "^2.4.0",
|
"lottie-react": "^2.4.0",
|
||||||
"lucide-react": "^0.331.0",
|
"lucide-react": "^0.451.0",
|
||||||
"mathjs": "^12.3.2",
|
"mathjs": "^12.3.2",
|
||||||
"mitt": "^3.0.1",
|
"mitt": "^3.0.1",
|
||||||
"next": "14.2.1",
|
"next": "14.2.1",
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ import {
|
|||||||
AreaChartIcon,
|
AreaChartIcon,
|
||||||
BarChart3Icon,
|
BarChart3Icon,
|
||||||
BarChartHorizontalIcon,
|
BarChartHorizontalIcon,
|
||||||
|
ChartScatterIcon,
|
||||||
ConeIcon,
|
ConeIcon,
|
||||||
Globe2Icon,
|
Globe2Icon,
|
||||||
|
Grid3X3Icon,
|
||||||
HashIcon,
|
HashIcon,
|
||||||
LayoutPanelTopIcon,
|
LayoutPanelTopIcon,
|
||||||
LineChartIcon,
|
LineChartIcon,
|
||||||
@@ -110,6 +112,7 @@ export function ListDashboards({ dashboards }: ListDashboardsProps) {
|
|||||||
histogram: BarChart3Icon,
|
histogram: BarChart3Icon,
|
||||||
funnel: ConeIcon,
|
funnel: ConeIcon,
|
||||||
area: AreaChartIcon,
|
area: AreaChartIcon,
|
||||||
|
retention: ChartScatterIcon,
|
||||||
}[report.chartType];
|
}[report.chartType];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { useAppParams } from '@/hooks/useAppParams';
|
|
||||||
import { pushModal } from '@/modals';
|
import { pushModal } from '@/modals';
|
||||||
import { cn } from '@/utils/cn';
|
import { cn } from '@/utils/cn';
|
||||||
import { useUser } from '@clerk/nextjs';
|
|
||||||
import {
|
import {
|
||||||
|
ChartLineIcon,
|
||||||
GanttChartIcon,
|
GanttChartIcon,
|
||||||
Globe2Icon,
|
Globe2Icon,
|
||||||
LayersIcon,
|
LayersIcon,
|
||||||
@@ -15,11 +14,10 @@ import {
|
|||||||
UsersIcon,
|
UsersIcon,
|
||||||
WallpaperIcon,
|
WallpaperIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import type { LucideProps } from 'lucide-react';
|
import type { LucideIcon } from 'lucide-react';
|
||||||
import Link from 'next/link';
|
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import { useEffect } from 'react';
|
|
||||||
|
|
||||||
|
import { ProjectLink } from '@/components/links';
|
||||||
import type { IServiceDashboards } from '@openpanel/db';
|
import type { IServiceDashboards } from '@openpanel/db';
|
||||||
|
|
||||||
function LinkWithIcon({
|
function LinkWithIcon({
|
||||||
@@ -30,7 +28,7 @@ function LinkWithIcon({
|
|||||||
className,
|
className,
|
||||||
}: {
|
}: {
|
||||||
href: string;
|
href: string;
|
||||||
icon: React.ElementType<LucideProps>;
|
icon: LucideIcon;
|
||||||
label: React.ReactNode;
|
label: React.ReactNode;
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -38,7 +36,7 @@ function LinkWithIcon({
|
|||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const active = overrideActive || href === pathname;
|
const active = overrideActive || href === pathname;
|
||||||
return (
|
return (
|
||||||
<Link
|
<ProjectLink
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-text flex items-center gap-2 rounded-md px-3 py-2 font-medium transition-all hover:bg-def-200',
|
'text-text flex items-center gap-2 rounded-md px-3 py-2 font-medium transition-all hover:bg-def-200',
|
||||||
active && 'bg-def-200',
|
active && 'bg-def-200',
|
||||||
@@ -48,7 +46,7 @@ function LinkWithIcon({
|
|||||||
>
|
>
|
||||||
<Icon size={20} />
|
<Icon size={20} />
|
||||||
<div className="flex-1">{label}</div>
|
<div className="flex-1">{label}</div>
|
||||||
</Link>
|
</ProjectLink>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,66 +54,34 @@ interface LayoutMenuProps {
|
|||||||
dashboards: IServiceDashboards;
|
dashboards: IServiceDashboards;
|
||||||
}
|
}
|
||||||
export default function LayoutMenu({ dashboards }: LayoutMenuProps) {
|
export default function LayoutMenu({ dashboards }: LayoutMenuProps) {
|
||||||
const { user } = useUser();
|
|
||||||
|
|
||||||
const params = useAppParams();
|
|
||||||
const hasProjectId =
|
|
||||||
params.projectId &&
|
|
||||||
params.projectId !== 'null' &&
|
|
||||||
params.projectId !== 'undefined';
|
|
||||||
const projectId = hasProjectId
|
|
||||||
? params.projectId
|
|
||||||
: (user?.unsafeMetadata.projectId as string);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (hasProjectId) {
|
|
||||||
user?.update({
|
|
||||||
unsafeMetadata: {
|
|
||||||
...user.unsafeMetadata,
|
|
||||||
projectId: params.projectId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [params.projectId, hasProjectId]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<LinkWithIcon
|
<ProjectLink
|
||||||
icon={WallpaperIcon}
|
href={'/reports'}
|
||||||
label="Overview"
|
className={cn(
|
||||||
href={`/${params.organizationSlug}/${projectId}`}
|
'border rounded p-2 row items-center gap-2 hover:bg-def-200 mb-4',
|
||||||
/>
|
)}
|
||||||
|
>
|
||||||
|
<ChartLineIcon size={20} />
|
||||||
|
<div className="flex-1 col gap-1">
|
||||||
|
<div className="font-medium">Create report</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Visualize your events
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<PlusIcon size={16} className="text-muted-foreground" />
|
||||||
|
</ProjectLink>
|
||||||
|
<LinkWithIcon icon={WallpaperIcon} label="Overview" href={'/'} />
|
||||||
<LinkWithIcon
|
<LinkWithIcon
|
||||||
icon={LayoutPanelTopIcon}
|
icon={LayoutPanelTopIcon}
|
||||||
label="Dashboards"
|
label="Dashboards"
|
||||||
href={`/${params.organizationSlug}/${projectId}/dashboards`}
|
href={'/dashboards'}
|
||||||
/>
|
/>
|
||||||
<LinkWithIcon
|
<LinkWithIcon icon={LayersIcon} label="Pages" href={'/pages'} />
|
||||||
icon={LayersIcon}
|
<LinkWithIcon icon={Globe2Icon} label="Realtime" href={'/realtime'} />
|
||||||
label="Pages"
|
<LinkWithIcon icon={GanttChartIcon} label="Events" href={'/events'} />
|
||||||
href={`/${params.organizationSlug}/${projectId}/pages`}
|
<LinkWithIcon icon={UsersIcon} label="Profiles" href={'/profiles'} />
|
||||||
/>
|
<LinkWithIcon icon={ScanEyeIcon} label="Retention" href={'/retention'} />
|
||||||
<LinkWithIcon
|
|
||||||
icon={Globe2Icon}
|
|
||||||
label="Realtime"
|
|
||||||
href={`/${params.organizationSlug}/${projectId}/realtime`}
|
|
||||||
/>
|
|
||||||
<LinkWithIcon
|
|
||||||
icon={GanttChartIcon}
|
|
||||||
label="Events"
|
|
||||||
href={`/${params.organizationSlug}/${projectId}/events`}
|
|
||||||
/>
|
|
||||||
<LinkWithIcon
|
|
||||||
icon={UsersIcon}
|
|
||||||
label="Profiles"
|
|
||||||
href={`/${params.organizationSlug}/${projectId}/profiles`}
|
|
||||||
/>
|
|
||||||
<LinkWithIcon
|
|
||||||
icon={ScanEyeIcon}
|
|
||||||
label="Retention"
|
|
||||||
href={`/${params.organizationSlug}/${projectId}/retention`}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<div className="mb-2 flex items-center justify-between">
|
<div className="mb-2 flex items-center justify-between">
|
||||||
<div className="text-muted-foreground">Your dashboards</div>
|
<div className="text-muted-foreground">Your dashboards</div>
|
||||||
@@ -134,7 +100,7 @@ export default function LayoutMenu({ dashboards }: LayoutMenuProps) {
|
|||||||
key={item.id}
|
key={item.id}
|
||||||
icon={LayoutPanelTopIcon}
|
icon={LayoutPanelTopIcon}
|
||||||
label={item.name}
|
label={item.name}
|
||||||
href={`/${item.organizationSlug}/${item.projectId}/dashboards/${item.id}`}
|
href={`/dashboards/${item.id}`}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -40,8 +40,8 @@ export function OverviewFiltersDrawerContent({
|
|||||||
const { interval, range, startDate, endDate } = useOverviewOptions();
|
const { interval, range, startDate, endDate } = useOverviewOptions();
|
||||||
const [filters, setFilter] = useEventQueryFilters(nuqsOptions);
|
const [filters, setFilter] = useEventQueryFilters(nuqsOptions);
|
||||||
const [event, setEvent] = useEventQueryNamesFilter(nuqsOptions);
|
const [event, setEvent] = useEventQueryNamesFilter(nuqsOptions);
|
||||||
const eventNames = useEventNames({ projectId, interval, range });
|
const eventNames = useEventNames({ projectId });
|
||||||
const eventProperties = useEventProperties({ projectId, interval, range });
|
const eventProperties = useEventProperties({ projectId, event: event[0] });
|
||||||
const profileProperties = useProfileProperties(projectId);
|
const profileProperties = useProfileProperties(projectId);
|
||||||
const properties = mode === 'events' ? eventProperties : profileProperties;
|
const properties = mode === 'events' ? eventProperties : profileProperties;
|
||||||
|
|
||||||
@@ -94,8 +94,6 @@ export function OverviewFiltersDrawerContent({
|
|||||||
eventName="screen_view"
|
eventName="screen_view"
|
||||||
key={filter.name}
|
key={filter.name}
|
||||||
filter={filter}
|
filter={filter}
|
||||||
range={range}
|
|
||||||
interval={interval}
|
|
||||||
onRemove={() => {
|
onRemove={() => {
|
||||||
setFilter(filter.name, [], filter.operator);
|
setFilter(filter.name, [], filter.operator);
|
||||||
}}
|
}}
|
||||||
@@ -105,8 +103,6 @@ export function OverviewFiltersDrawerContent({
|
|||||||
onChangeOperator={(operator) => {
|
onChangeOperator={(operator) => {
|
||||||
setFilter(filter.name, filter.value, operator);
|
setFilter(filter.name, filter.value, operator);
|
||||||
}}
|
}}
|
||||||
startDate={startDate}
|
|
||||||
endDate={endDate}
|
|
||||||
/>
|
/>
|
||||||
) : /* TODO: Implement profile filters */
|
) : /* TODO: Implement profile filters */
|
||||||
null;
|
null;
|
||||||
@@ -128,13 +124,10 @@ export function FilterOptionEvent({
|
|||||||
operator: IChartEventFilterOperator,
|
operator: IChartEventFilterOperator,
|
||||||
) => void;
|
) => void;
|
||||||
}) {
|
}) {
|
||||||
const { interval, range } = useOverviewOptions();
|
|
||||||
const values = usePropertyValues({
|
const values = usePropertyValues({
|
||||||
projectId,
|
projectId,
|
||||||
event: filter.name === 'path' ? 'screen_view' : 'session_start',
|
event: filter.name === 'path' ? 'screen_view' : 'session_start',
|
||||||
property: filter.name,
|
property: filter.name,
|
||||||
interval,
|
|
||||||
range,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -25,9 +25,11 @@ export function getYAxisWidth(value: string | undefined | null) {
|
|||||||
export const useYAxisProps = ({
|
export const useYAxisProps = ({
|
||||||
data,
|
data,
|
||||||
hide,
|
hide,
|
||||||
|
tickFormatter,
|
||||||
}: {
|
}: {
|
||||||
data: number[];
|
data: number[];
|
||||||
hide?: boolean;
|
hide?: boolean;
|
||||||
|
tickFormatter?: (value: number) => string;
|
||||||
}) => {
|
}) => {
|
||||||
const [width, setWidth] = useState(24);
|
const [width, setWidth] = useState(24);
|
||||||
const setWidthDebounced = useDebounceFn(setWidth, 100);
|
const setWidthDebounced = useDebounceFn(setWidth, 100);
|
||||||
@@ -41,7 +43,7 @@ export const useYAxisProps = ({
|
|||||||
tickLine: false,
|
tickLine: false,
|
||||||
allowDecimals: false,
|
allowDecimals: false,
|
||||||
tickFormatter: (value: number) => {
|
tickFormatter: (value: number) => {
|
||||||
const tick = number.short(value);
|
const tick = tickFormatter ? tickFormatter(value) : number.short(value);
|
||||||
const newWidth = getYAxisWidth(tick);
|
const newWidth = getYAxisWidth(tick);
|
||||||
ref.current.push(newWidth);
|
ref.current.push(newWidth);
|
||||||
setWidthDebounced(Math.max(...ref.current));
|
setWidthDebounced(Math.max(...ref.current));
|
||||||
|
|||||||
@@ -1,13 +1,62 @@
|
|||||||
import { BirdIcon } from 'lucide-react';
|
import { cn } from '@/utils/cn';
|
||||||
|
import {
|
||||||
|
ArrowUpLeftIcon,
|
||||||
|
BirdIcon,
|
||||||
|
CornerLeftUpIcon,
|
||||||
|
Forklift,
|
||||||
|
ForkliftIcon,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useReportChartContext } from '../context';
|
||||||
|
|
||||||
|
export function ReportChartEmpty({
|
||||||
|
title = 'No data',
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title?: string;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
isEditMode,
|
||||||
|
report: { events },
|
||||||
|
} = useReportChartContext();
|
||||||
|
|
||||||
|
if (events.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="card p-4 center-center h-full w-full flex-col relative">
|
||||||
|
<div className="row gap-2 items-end absolute top-4 left-4">
|
||||||
|
<CornerLeftUpIcon
|
||||||
|
strokeWidth={1.2}
|
||||||
|
className="size-8 animate-pulse text-muted-foreground"
|
||||||
|
/>
|
||||||
|
<div className="text-muted-foreground">Start here</div>
|
||||||
|
</div>
|
||||||
|
<ForkliftIcon
|
||||||
|
strokeWidth={1.2}
|
||||||
|
className="mb-4 size-1/3 animate-pulse text-muted-foreground"
|
||||||
|
/>
|
||||||
|
<div className="font-medium text-muted-foreground">
|
||||||
|
Ready when you're
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground mt-2">
|
||||||
|
Pick atleast one event to start visualize
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function ReportChartEmpty() {
|
|
||||||
return (
|
return (
|
||||||
<div className="center-center h-full w-full flex-col">
|
<div
|
||||||
|
className={cn(
|
||||||
|
'center-center h-full w-full flex-col',
|
||||||
|
isEditMode && 'card p-4',
|
||||||
|
)}
|
||||||
|
>
|
||||||
<BirdIcon
|
<BirdIcon
|
||||||
strokeWidth={1.2}
|
strokeWidth={1.2}
|
||||||
className="mb-4 size-10 animate-pulse text-muted-foreground"
|
className="mb-4 size-1/3 animate-pulse text-muted-foreground"
|
||||||
/>
|
/>
|
||||||
<div className="text-sm font-medium text-muted-foreground">No data</div>
|
<div className="font-medium text-muted-foreground">{title}</div>
|
||||||
|
<div className="text-muted-foreground mt-2">{children}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
|
import { cn } from '@/utils/cn';
|
||||||
import { ServerCrashIcon } from 'lucide-react';
|
import { ServerCrashIcon } from 'lucide-react';
|
||||||
|
import { useReportChartContext } from '../context';
|
||||||
|
|
||||||
export function ReportChartError() {
|
export function ReportChartError() {
|
||||||
|
const { isEditMode } = useReportChartContext();
|
||||||
return (
|
return (
|
||||||
<div className="center-center h-full w-full flex-col">
|
<div
|
||||||
|
className={cn(
|
||||||
|
'center-center h-full w-full flex-col',
|
||||||
|
isEditMode && 'card p-4',
|
||||||
|
)}
|
||||||
|
>
|
||||||
<ServerCrashIcon
|
<ServerCrashIcon
|
||||||
strokeWidth={1.2}
|
strokeWidth={1.2}
|
||||||
className="mb-4 size-10 animate-pulse text-muted-foreground"
|
className="mb-4 size-10 animate-pulse text-muted-foreground"
|
||||||
|
|||||||
@@ -1,3 +1,88 @@
|
|||||||
export function ReportChartLoading() {
|
import { cn } from '@/utils/cn';
|
||||||
return <div className="h-full w-full animate-pulse rounded bg-def-100" />;
|
import { AnimatePresence, motion } from 'framer-motion';
|
||||||
|
import {
|
||||||
|
ActivityIcon,
|
||||||
|
AlarmClockIcon,
|
||||||
|
BarChart2Icon,
|
||||||
|
BarChartIcon,
|
||||||
|
ChartLineIcon,
|
||||||
|
ChartPieIcon,
|
||||||
|
LineChartIcon,
|
||||||
|
MessagesSquareIcon,
|
||||||
|
PieChartIcon,
|
||||||
|
TrendingUpIcon,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { useReportChartContext } from '../context';
|
||||||
|
|
||||||
|
const icons = [
|
||||||
|
{ Icon: ActivityIcon, color: 'text-chart-6' },
|
||||||
|
{ Icon: BarChart2Icon, color: 'text-chart-9' },
|
||||||
|
{ Icon: ChartLineIcon, color: 'text-chart-0' },
|
||||||
|
{ Icon: AlarmClockIcon, color: 'text-chart-1' },
|
||||||
|
{ Icon: ChartPieIcon, color: 'text-chart-2' },
|
||||||
|
{ Icon: MessagesSquareIcon, color: 'text-chart-3' },
|
||||||
|
{ Icon: BarChartIcon, color: 'text-chart-4' },
|
||||||
|
{ Icon: TrendingUpIcon, color: 'text-chart-5' },
|
||||||
|
{ Icon: PieChartIcon, color: 'text-chart-7' },
|
||||||
|
{ Icon: LineChartIcon, color: 'text-chart-8' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function ReportChartLoading({ things }: { things?: boolean }) {
|
||||||
|
const { isEditMode } = useReportChartContext();
|
||||||
|
const [currentIconIndex, setCurrentIconIndex] = React.useState(0);
|
||||||
|
const [isSlow, setSlow] = useState(false);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setCurrentIconIndex((prevIndex) => (prevIndex + 1) % icons.length);
|
||||||
|
}, 1500);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentIconIndex >= 3) {
|
||||||
|
setSlow(true);
|
||||||
|
}
|
||||||
|
}, [currentIconIndex]);
|
||||||
|
|
||||||
|
const { Icon, color } = icons[currentIconIndex]!;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('h-full w-full', isEditMode && 'card p-4')}>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
'relative h-full w-full rounded bg-def-100 overflow-hidden center-center flex'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<AnimatePresence initial={false} mode="wait">
|
||||||
|
<motion.div
|
||||||
|
key={currentIconIndex}
|
||||||
|
initial={{ x: '100%', opacity: 0 }}
|
||||||
|
animate={{ x: 0, opacity: 1 }}
|
||||||
|
exit={{ x: '-100%', opacity: 0 }}
|
||||||
|
transition={{
|
||||||
|
type: 'spring',
|
||||||
|
stiffness: 500,
|
||||||
|
damping: 30,
|
||||||
|
duration: 0.5,
|
||||||
|
}}
|
||||||
|
className={cn('absolute size-1/3', color)}
|
||||||
|
>
|
||||||
|
<Icon className="w-full h-full" />
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'absolute top-3/4 opacity-0 transition-opacity text-muted-foreground',
|
||||||
|
isSlow && 'opacity-100',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Stay calm, its coming 🙄
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import type { IChartData } from '@/trpc/client';
|
|||||||
import { getChartColor } from '@/utils/theme';
|
import { getChartColor } from '@/utils/theme';
|
||||||
import type * as React from 'react';
|
import type * as React from 'react';
|
||||||
|
|
||||||
|
import { logDependencies } from 'mathjs';
|
||||||
import { PreviousDiffIndicator } from './previous-diff-indicator';
|
import { PreviousDiffIndicator } from './previous-diff-indicator';
|
||||||
import { SerieName } from './serie-name';
|
import { SerieName } from './serie-name';
|
||||||
|
|
||||||
@@ -80,7 +81,7 @@ export function ReportTable({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow key={serie.id}>
|
<TableRow key={`${serie.id}-1`}>
|
||||||
{serie.names.map((name, nameIndex) => {
|
{serie.names.map((name, nameIndex) => {
|
||||||
return (
|
return (
|
||||||
<TableCell className="h-10" key={name}>
|
<TableCell className="h-10" key={name}>
|
||||||
@@ -140,7 +141,7 @@ export function ReportTable({
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
{paginate(data.series).map((serie) => {
|
{paginate(data.series).map((serie) => {
|
||||||
return (
|
return (
|
||||||
<TableRow key={serie.id}>
|
<TableRow key={`${serie.id}-2`}>
|
||||||
<TableCell className="h-10">
|
<TableCell className="h-10">
|
||||||
<div className="flex items-center gap-2 font-medium">
|
<div className="flex items-center gap-2 font-medium">
|
||||||
{number.format(serie.metrics.sum)}
|
{number.format(serie.metrics.sum)}
|
||||||
|
|||||||
@@ -3,10 +3,8 @@
|
|||||||
import { ColorSquare } from '@/components/color-square';
|
import { ColorSquare } from '@/components/color-square';
|
||||||
import { TooltipComplete } from '@/components/tooltip-complete';
|
import { TooltipComplete } from '@/components/tooltip-complete';
|
||||||
import { Progress } from '@/components/ui/progress';
|
import { Progress } from '@/components/ui/progress';
|
||||||
import { Widget, WidgetBody } from '@/components/widget';
|
|
||||||
import type { RouterOutputs } from '@/trpc/client';
|
import type { RouterOutputs } from '@/trpc/client';
|
||||||
import { cn } from '@/utils/cn';
|
import { cn } from '@/utils/cn';
|
||||||
import { getChartColor } from '@/utils/theme';
|
|
||||||
import { AlertCircleIcon } from 'lucide-react';
|
import { AlertCircleIcon } from 'lucide-react';
|
||||||
import { last } from 'ramda';
|
import { last } from 'ramda';
|
||||||
|
|
||||||
@@ -70,7 +68,7 @@ export function Chart({
|
|||||||
/>
|
/>
|
||||||
<MetricCardNumber
|
<MetricCardNumber
|
||||||
label="Percent"
|
label="Percent"
|
||||||
value={`${round((lastStep.count / totalSessions) * 100, 1)}%`}
|
value={`${totalSessions ? round((lastStep.count / totalSessions) * 100, 1) : 0}%`}
|
||||||
enhancer={
|
enhancer={
|
||||||
<PreviousDiffIndicator
|
<PreviousDiffIndicator
|
||||||
size="lg"
|
size="lg"
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { ReportLineChart } from './line';
|
|||||||
import { ReportMapChart } from './map';
|
import { ReportMapChart } from './map';
|
||||||
import { ReportMetricChart } from './metric';
|
import { ReportMetricChart } from './metric';
|
||||||
import { ReportPieChart } from './pie';
|
import { ReportPieChart } from './pie';
|
||||||
|
import { ReportRetentionChart } from './retention';
|
||||||
|
|
||||||
export function ReportChart(props: ReportChartProps) {
|
export function ReportChart(props: ReportChartProps) {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
@@ -48,6 +49,8 @@ export function ReportChart(props: ReportChartProps) {
|
|||||||
return <ReportMetricChart />;
|
return <ReportMetricChart />;
|
||||||
case 'funnel':
|
case 'funnel':
|
||||||
return <ReportFunnelChart />;
|
return <ReportFunnelChart />;
|
||||||
|
case 'retention':
|
||||||
|
return <ReportRetentionChart />;
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { api } from '@/trpc/client';
|
import { api } from '@/trpc/client';
|
||||||
|
|
||||||
|
import { cn } from '@/utils/cn';
|
||||||
import { AspectContainer } from '../aspect-container';
|
import { AspectContainer } from '../aspect-container';
|
||||||
import { ReportChartEmpty } from '../common/empty';
|
import { ReportChartEmpty } from '../common/empty';
|
||||||
import { ReportChartError } from '../common/error';
|
import { ReportChartError } from '../common/error';
|
||||||
|
|||||||
113
apps/dashboard/src/components/report-chart/retention/chart.tsx
Normal file
113
apps/dashboard/src/components/report-chart/retention/chart.tsx
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import type { RouterOutputs } from '@/trpc/client';
|
||||||
|
import { cn } from '@/utils/cn';
|
||||||
|
import { getChartColor } from '@/utils/theme';
|
||||||
|
import {
|
||||||
|
Area,
|
||||||
|
CartesianGrid,
|
||||||
|
ComposedChart,
|
||||||
|
ReferenceLine,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from 'recharts';
|
||||||
|
|
||||||
|
import { average, round } from '@openpanel/common';
|
||||||
|
import { fix } from 'mathjs';
|
||||||
|
import { useXAxisProps, useYAxisProps } from '../common/axis';
|
||||||
|
import { useReportChartContext } from '../context';
|
||||||
|
import { RetentionTooltip } from './tooltip';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: RouterOutputs['chart']['cohort'];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Chart({ data }: Props) {
|
||||||
|
const {
|
||||||
|
report: { interval },
|
||||||
|
isEditMode,
|
||||||
|
options: { hideXAxis, hideYAxis },
|
||||||
|
} = useReportChartContext();
|
||||||
|
|
||||||
|
const xAxisProps = useXAxisProps({ interval, hide: hideXAxis });
|
||||||
|
const yAxisProps = useYAxisProps({
|
||||||
|
data: [100],
|
||||||
|
hide: hideYAxis,
|
||||||
|
tickFormatter: (value) => `${value}%`,
|
||||||
|
});
|
||||||
|
const averageRow = data[0];
|
||||||
|
const averageRetentionRate = average(averageRow?.percentages || [], true);
|
||||||
|
const rechartData = averageRow?.percentages.map((item, index, list) => ({
|
||||||
|
days: index,
|
||||||
|
percentage: item,
|
||||||
|
value: averageRow.values[index],
|
||||||
|
sum: averageRow.sum,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={cn('h-full w-full', isEditMode && 'card p-4')}>
|
||||||
|
<ResponsiveContainer>
|
||||||
|
<ComposedChart data={rechartData}>
|
||||||
|
<CartesianGrid
|
||||||
|
strokeDasharray="3 3"
|
||||||
|
horizontal={true}
|
||||||
|
vertical={true}
|
||||||
|
className="stroke-border"
|
||||||
|
/>
|
||||||
|
<YAxis {...yAxisProps} dataKey="retentionRate" domain={[0, 100]} />
|
||||||
|
<XAxis
|
||||||
|
{...xAxisProps}
|
||||||
|
dataKey="days"
|
||||||
|
allowDuplicatedCategory
|
||||||
|
scale="linear"
|
||||||
|
tickFormatter={(value) => value.toString()}
|
||||||
|
tickCount={31}
|
||||||
|
interval={0}
|
||||||
|
/>
|
||||||
|
<Tooltip content={<RetentionTooltip />} />
|
||||||
|
<defs>
|
||||||
|
<linearGradient id={'color'} x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop
|
||||||
|
offset="0%"
|
||||||
|
stopColor={getChartColor(0)}
|
||||||
|
stopOpacity={0.8}
|
||||||
|
/>
|
||||||
|
<stop
|
||||||
|
offset="100%"
|
||||||
|
stopColor={getChartColor(0)}
|
||||||
|
stopOpacity={0.1}
|
||||||
|
/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<ReferenceLine
|
||||||
|
y={averageRetentionRate}
|
||||||
|
stroke={getChartColor(1)}
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeDasharray="3 3"
|
||||||
|
strokeOpacity={0.5}
|
||||||
|
strokeLinecap="round"
|
||||||
|
label={{
|
||||||
|
value: `Average (${round(averageRetentionRate, 2)} %)`,
|
||||||
|
fill: getChartColor(1),
|
||||||
|
position: 'insideBottomRight',
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
dataKey="percentage"
|
||||||
|
fill={'url(#color)'}
|
||||||
|
type={'monotone'}
|
||||||
|
isAnimationActive={false}
|
||||||
|
strokeWidth={2}
|
||||||
|
stroke={getChartColor(0)}
|
||||||
|
fillOpacity={0.1}
|
||||||
|
/>
|
||||||
|
</ComposedChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
103
apps/dashboard/src/components/report-chart/retention/index.tsx
Normal file
103
apps/dashboard/src/components/report-chart/retention/index.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { api } from '@/trpc/client';
|
||||||
|
|
||||||
|
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';
|
||||||
|
import CohortTable from './table';
|
||||||
|
|
||||||
|
export function ReportRetentionChart() {
|
||||||
|
const {
|
||||||
|
report: {
|
||||||
|
events,
|
||||||
|
range,
|
||||||
|
projectId,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
criteria,
|
||||||
|
interval,
|
||||||
|
},
|
||||||
|
isLazyLoading,
|
||||||
|
} = useReportChartContext();
|
||||||
|
const firstEvent = (events[0]?.filters[0]?.value ?? []).map(String);
|
||||||
|
const secondEvent = (events[1]?.filters[0]?.value ?? []).map(String);
|
||||||
|
const isEnabled = firstEvent.length > 0 && secondEvent.length > 0;
|
||||||
|
const res = api.chart.cohort.useQuery(
|
||||||
|
{
|
||||||
|
firstEvent,
|
||||||
|
secondEvent,
|
||||||
|
projectId,
|
||||||
|
range,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
criteria,
|
||||||
|
interval,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: isEnabled,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isEnabled) {
|
||||||
|
return <Disabled />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLazyLoading || res.isLoading) {
|
||||||
|
return <Loading />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.isError) {
|
||||||
|
return <Error />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.data.length === 0) {
|
||||||
|
return <Empty />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="col gap-4">
|
||||||
|
<AspectContainer>
|
||||||
|
<Chart data={res.data} />
|
||||||
|
</AspectContainer>
|
||||||
|
<CohortTable data={res.data} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Loading() {
|
||||||
|
return (
|
||||||
|
<AspectContainer>
|
||||||
|
<ReportChartLoading />
|
||||||
|
</AspectContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Error() {
|
||||||
|
return (
|
||||||
|
<AspectContainer>
|
||||||
|
<ReportChartError />
|
||||||
|
</AspectContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Empty() {
|
||||||
|
return (
|
||||||
|
<AspectContainer>
|
||||||
|
<ReportChartEmpty />
|
||||||
|
</AspectContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Disabled() {
|
||||||
|
return (
|
||||||
|
<AspectContainer>
|
||||||
|
<ReportChartEmpty title="Select 2 events">
|
||||||
|
We need two events to determine the retention rate.
|
||||||
|
</ReportChartEmpty>
|
||||||
|
</AspectContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
136
apps/dashboard/src/components/report-chart/retention/table.tsx
Normal file
136
apps/dashboard/src/components/report-chart/retention/table.tsx
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import { ProjectLink } from '@/components/links';
|
||||||
|
import { Tooltiper } from '@/components/ui/tooltip';
|
||||||
|
import { useNumber } from '@/hooks/useNumerFormatter';
|
||||||
|
import type { RouterOutputs } from '@/trpc/client';
|
||||||
|
import { cn } from '@/utils/cn';
|
||||||
|
import { max, min, sum } from '@openpanel/common';
|
||||||
|
import { intervals } from '@openpanel/constants';
|
||||||
|
import type React from 'react';
|
||||||
|
import { useReportChartContext } from '../context';
|
||||||
|
|
||||||
|
type CohortData = RouterOutputs['chart']['cohort'];
|
||||||
|
|
||||||
|
type CohortTableProps = {
|
||||||
|
data: CohortData;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CohortTable: React.FC<CohortTableProps> = ({ data }) => {
|
||||||
|
const {
|
||||||
|
report: { unit, interval },
|
||||||
|
} = useReportChartContext();
|
||||||
|
const isPercentage = unit === '%';
|
||||||
|
const number = useNumber();
|
||||||
|
const highestValue = max(data.map((row) => max(row.values)));
|
||||||
|
const lowestValue = min(data.map((row) => min(row.values)));
|
||||||
|
const rowWithHigestSum = data.find(
|
||||||
|
(row) => row.sum === max(data.map((row) => row.sum)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const getBackground = (value: number | undefined) => {
|
||||||
|
if (!value)
|
||||||
|
return {
|
||||||
|
backgroundClassName: '',
|
||||||
|
opacity: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const percentage = isPercentage
|
||||||
|
? value / 100
|
||||||
|
: (value - lowestValue) / (highestValue - lowestValue);
|
||||||
|
const opacity = Math.max(0.05, percentage);
|
||||||
|
|
||||||
|
return {
|
||||||
|
backgroundClassName: 'bg-highlight dark:bg-emerald-700',
|
||||||
|
opacity,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const thClassName =
|
||||||
|
'h-10 align-top pt-3 whitespace-nowrap font-semibold text-muted-foreground';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative card overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={'h-10 absolute left-0 right-0 top-px bg-def-100 border-b'}
|
||||||
|
/>
|
||||||
|
<div className="w-full overflow-x-auto hide-scrollbar">
|
||||||
|
<div className="min-w-full relative">
|
||||||
|
<table className="w-full table-auto whitespace-nowrap">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className={cn(thClassName, 'sticky left-0 z-10')}>
|
||||||
|
<div className="bg-def-100">
|
||||||
|
<div className="h-10 center-center -mt-3">Date</div>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th className={cn(thClassName, 'pr-1')}>Total profiles</th>
|
||||||
|
{data[0]?.values.map((column, index) => (
|
||||||
|
<th
|
||||||
|
key={index.toString()}
|
||||||
|
className={cn(thClassName, 'capitalize')}
|
||||||
|
>
|
||||||
|
{index === 0 ? `< ${interval} 1` : `${interval} ${index}`}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.map((row) => {
|
||||||
|
const values = isPercentage ? row.percentages : row.values;
|
||||||
|
return (
|
||||||
|
<tr key={row.cohort_interval}>
|
||||||
|
<td className="sticky left-0 bg-card z-10 w-36 p-0">
|
||||||
|
<div className="h-10 center-center font-medium text-muted-foreground px-4">
|
||||||
|
{row.cohort_interval}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="p-0 min-w-12">
|
||||||
|
<div className={cn('font-mono rounded px-3 font-medium')}>
|
||||||
|
{number.format(row?.sum)}
|
||||||
|
{row.cohort_interval ===
|
||||||
|
rowWithHigestSum?.cohort_interval && ' 🚀'}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
{values.map((value, index) => {
|
||||||
|
const { opacity, backgroundClassName } =
|
||||||
|
getBackground(value);
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
key={row.cohort_interval + index.toString()}
|
||||||
|
className="p-0 min-w-24"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'h-10 center-center font-mono hover:shadow-[inset_0_0_0_2px_rgb(255,255,255)] relative',
|
||||||
|
opacity > 0.7 &&
|
||||||
|
'text-white [text-shadow:_0_0_3px_rgb(0_0_0_/_20%)]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
backgroundClassName,
|
||||||
|
'w-full h-full inset-0 absolute',
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
opacity,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="relative">
|
||||||
|
{number.formatWithUnit(value, unit)}
|
||||||
|
{value === highestValue && ' 🚀'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CohortTable;
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { useNumber } from '@/hooks/useNumerFormatter';
|
||||||
|
import type { RouterOutputs } from '@/trpc/client';
|
||||||
|
import { useReportChartContext } from '../context';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
active?: boolean;
|
||||||
|
payload?: Array<{
|
||||||
|
payload: any;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
export function RetentionTooltip({ active, payload }: Props) {
|
||||||
|
const {
|
||||||
|
report: { interval },
|
||||||
|
} = useReportChartContext();
|
||||||
|
const number = useNumber();
|
||||||
|
if (!active) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!payload?.[0]) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { days, percentage, value, sum } = payload[0].payload;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-w-[200px] flex-col gap-2 rounded-xl border bg-card p-3 shadow-xl">
|
||||||
|
<h3 className="font-semibold capitalize">
|
||||||
|
{interval} {days}
|
||||||
|
</h3>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Retention Rate:</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{number.formatWithUnit(percentage, '%')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Retained Users:</span>
|
||||||
|
<span className="font-medium">{number.format(value)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Total Users:</span>
|
||||||
|
<span className="font-medium">{number.format(sum)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -21,7 +21,8 @@ export function ReportInterval({ className }: ReportIntervalProps) {
|
|||||||
chartType !== 'linear' &&
|
chartType !== 'linear' &&
|
||||||
chartType !== 'histogram' &&
|
chartType !== 'histogram' &&
|
||||||
chartType !== 'area' &&
|
chartType !== 'area' &&
|
||||||
chartType !== 'metric'
|
chartType !== 'metric' &&
|
||||||
|
chartType !== 'retention'
|
||||||
) {
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,9 @@ import type {
|
|||||||
IChartRange,
|
IChartRange,
|
||||||
IChartType,
|
IChartType,
|
||||||
IInterval,
|
IInterval,
|
||||||
|
zCriteria,
|
||||||
} from '@openpanel/validation';
|
} from '@openpanel/validation';
|
||||||
|
import type { z } from 'zod';
|
||||||
|
|
||||||
type InitialState = IChartProps & {
|
type InitialState = IChartProps & {
|
||||||
dirty: boolean;
|
dirty: boolean;
|
||||||
@@ -53,6 +55,7 @@ const initialState: InitialState = {
|
|||||||
unit: undefined,
|
unit: undefined,
|
||||||
metric: 'sum',
|
metric: 'sum',
|
||||||
limit: 500,
|
limit: 500,
|
||||||
|
criteria: 'on_or_after',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const reportSlice = createSlice({
|
export const reportSlice = createSlice({
|
||||||
@@ -251,6 +254,18 @@ export const reportSlice = createSlice({
|
|||||||
state.dirty = true;
|
state.dirty = true;
|
||||||
state.formula = action.payload;
|
state.formula = action.payload;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
changeCriteria(state, action: PayloadAction<z.infer<typeof zCriteria>>) {
|
||||||
|
state.dirty = true;
|
||||||
|
state.criteria = action.payload;
|
||||||
|
},
|
||||||
|
|
||||||
|
changeUnit(state, action: PayloadAction<string | undefined>) {
|
||||||
|
console.log('here?!?!', action.payload);
|
||||||
|
|
||||||
|
state.dirty = true;
|
||||||
|
state.unit = action.payload || undefined;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -276,6 +291,8 @@ export const {
|
|||||||
resetDirty,
|
resetDirty,
|
||||||
changeFormula,
|
changeFormula,
|
||||||
changePrevious,
|
changePrevious,
|
||||||
|
changeCriteria,
|
||||||
|
changeUnit,
|
||||||
} = reportSlice.actions;
|
} = reportSlice.actions;
|
||||||
|
|
||||||
export default reportSlice.reducer;
|
export default reportSlice.reducer;
|
||||||
|
|||||||
@@ -19,14 +19,10 @@ export function EventPropertiesCombobox({
|
|||||||
}: EventPropertiesComboboxProps) {
|
}: EventPropertiesComboboxProps) {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const { projectId } = useAppParams();
|
const { projectId } = useAppParams();
|
||||||
const range = useSelector((state) => state.report.range);
|
|
||||||
const interval = useSelector((state) => state.report.interval);
|
|
||||||
const properties = useEventProperties(
|
const properties = useEventProperties(
|
||||||
{
|
{
|
||||||
event: event.name,
|
event: event.name,
|
||||||
projectId,
|
projectId,
|
||||||
range,
|
|
||||||
interval,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
enabled: !!event.name,
|
enabled: !!event.name,
|
||||||
|
|||||||
@@ -16,14 +16,10 @@ import type { ReportEventMoreProps } from './ReportEventMore';
|
|||||||
export function ReportBreakdowns() {
|
export function ReportBreakdowns() {
|
||||||
const { projectId } = useAppParams();
|
const { projectId } = useAppParams();
|
||||||
const selectedBreakdowns = useSelector((state) => state.report.breakdowns);
|
const selectedBreakdowns = useSelector((state) => state.report.breakdowns);
|
||||||
const interval = useSelector((state) => state.report.interval);
|
|
||||||
const range = useSelector((state) => state.report.range);
|
|
||||||
|
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const properties = useEventProperties({
|
const properties = useEventProperties({
|
||||||
projectId,
|
projectId,
|
||||||
range,
|
|
||||||
interval,
|
|
||||||
}).map((item) => ({
|
}).map((item) => ({
|
||||||
value: item,
|
value: item,
|
||||||
label: item, // <RenderDots truncate>{item}</RenderDots>,
|
label: item, // <RenderDots truncate>{item}</RenderDots>,
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { ColorSquare } from '@/components/color-square';
|
import { ColorSquare } from '@/components/color-square';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
|
||||||
import { Combobox } from '@/components/ui/combobox';
|
import { Combobox } from '@/components/ui/combobox';
|
||||||
import { DropdownMenuComposed } from '@/components/ui/dropdown-menu';
|
import { DropdownMenuComposed } from '@/components/ui/dropdown-menu';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@@ -14,12 +13,8 @@ import { GanttChart, GanttChartIcon, Users } from 'lucide-react';
|
|||||||
import { alphabetIds } from '@openpanel/constants';
|
import { alphabetIds } from '@openpanel/constants';
|
||||||
import type { IChartEvent } from '@openpanel/validation';
|
import type { IChartEvent } from '@openpanel/validation';
|
||||||
|
|
||||||
import {
|
import { ComboboxAdvanced } from '@/components/ui/combobox-advanced';
|
||||||
addEvent,
|
import { addEvent, changeEvent, removeEvent } from '../reportSlice';
|
||||||
changeEvent,
|
|
||||||
changePrevious,
|
|
||||||
removeEvent,
|
|
||||||
} from '../reportSlice';
|
|
||||||
import { EventPropertiesCombobox } from './EventPropertiesCombobox';
|
import { EventPropertiesCombobox } from './EventPropertiesCombobox';
|
||||||
import { ReportEventMore } from './ReportEventMore';
|
import { ReportEventMore } from './ReportEventMore';
|
||||||
import type { ReportEventMoreProps } from './ReportEventMore';
|
import type { ReportEventMoreProps } from './ReportEventMore';
|
||||||
@@ -27,25 +22,22 @@ import { FiltersCombobox } from './filters/FiltersCombobox';
|
|||||||
import { FiltersList } from './filters/FiltersList';
|
import { FiltersList } from './filters/FiltersList';
|
||||||
|
|
||||||
export function ReportEvents() {
|
export function ReportEvents() {
|
||||||
const previous = useSelector((state) => state.report.previous);
|
|
||||||
const selectedEvents = useSelector((state) => state.report.events);
|
const selectedEvents = useSelector((state) => state.report.events);
|
||||||
const startDate = useSelector((state) => state.report.startDate);
|
const chartType = useSelector((state) => state.report.chartType);
|
||||||
const endDate = useSelector((state) => state.report.endDate);
|
|
||||||
const range = useSelector((state) => state.report.range);
|
|
||||||
const interval = useSelector((state) => state.report.interval);
|
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const { projectId } = useAppParams();
|
const { projectId } = useAppParams();
|
||||||
const eventNames = useEventNames({
|
const eventNames = useEventNames({
|
||||||
projectId,
|
projectId,
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
range,
|
|
||||||
interval,
|
|
||||||
});
|
});
|
||||||
|
const showSegment = !['retention', 'funnel'].includes(chartType);
|
||||||
|
const showAddFilter = !['retention'].includes(chartType);
|
||||||
|
const showDisplayNameInput = !['retention'].includes(chartType);
|
||||||
|
const isAddEventDisabled =
|
||||||
|
chartType === 'retention' && selectedEvents.length >= 2;
|
||||||
const dispatchChangeEvent = useDebounceFn((event: IChartEvent) => {
|
const dispatchChangeEvent = useDebounceFn((event: IChartEvent) => {
|
||||||
dispatch(changeEvent(event));
|
dispatch(changeEvent(event));
|
||||||
});
|
});
|
||||||
|
const isSelectManyEvents = chartType === 'retention';
|
||||||
|
|
||||||
const handleMore = (event: IChartEvent) => {
|
const handleMore = (event: IChartEvent) => {
|
||||||
const callback: ReportEventMoreProps['onClick'] = (action) => {
|
const callback: ReportEventMoreProps['onClick'] = (action) => {
|
||||||
@@ -68,137 +60,173 @@ export function ReportEvents() {
|
|||||||
<div key={event.id} className="rounded-lg border bg-def-100">
|
<div key={event.id} className="rounded-lg border bg-def-100">
|
||||||
<div className="flex items-center gap-2 p-2">
|
<div className="flex items-center gap-2 p-2">
|
||||||
<ColorSquare>{alphabetIds[index]}</ColorSquare>
|
<ColorSquare>{alphabetIds[index]}</ColorSquare>
|
||||||
<Combobox
|
{isSelectManyEvents ? (
|
||||||
icon={GanttChartIcon}
|
<ComboboxAdvanced
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
searchable
|
value={event.filters[0]?.value ?? []}
|
||||||
value={event.name}
|
onChange={(value) => {
|
||||||
onChange={(value) => {
|
dispatch(
|
||||||
dispatch(
|
changeEvent({
|
||||||
changeEvent({
|
id: event.id,
|
||||||
|
segment: 'user',
|
||||||
|
filters: [
|
||||||
|
{
|
||||||
|
name: 'name',
|
||||||
|
operator: 'is',
|
||||||
|
value: value,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
name: '*',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
items={eventNames.map((item) => ({
|
||||||
|
label: item.name,
|
||||||
|
value: item.name,
|
||||||
|
}))}
|
||||||
|
placeholder="Select event"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Combobox
|
||||||
|
icon={GanttChartIcon}
|
||||||
|
className="flex-1"
|
||||||
|
searchable
|
||||||
|
value={event.name}
|
||||||
|
onChange={(value) => {
|
||||||
|
dispatch(
|
||||||
|
changeEvent({
|
||||||
|
...event,
|
||||||
|
name: value,
|
||||||
|
filters: [],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
items={eventNames.map((item) => ({
|
||||||
|
label: item.name,
|
||||||
|
value: item.name,
|
||||||
|
}))}
|
||||||
|
placeholder="Select event"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{showDisplayNameInput && (
|
||||||
|
<Input
|
||||||
|
placeholder={
|
||||||
|
event.name
|
||||||
|
? `${event.name} (${alphabetIds[index]})`
|
||||||
|
: 'Display name'
|
||||||
|
}
|
||||||
|
defaultValue={event.displayName}
|
||||||
|
onChange={(e) => {
|
||||||
|
dispatchChangeEvent({
|
||||||
...event,
|
...event,
|
||||||
name: value,
|
displayName: e.target.value,
|
||||||
filters: [],
|
});
|
||||||
}),
|
}}
|
||||||
);
|
/>
|
||||||
}}
|
)}
|
||||||
items={eventNames.map((item) => ({
|
|
||||||
label: item.name,
|
|
||||||
value: item.name,
|
|
||||||
}))}
|
|
||||||
placeholder="Select event"
|
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
placeholder={
|
|
||||||
event.name
|
|
||||||
? `${event.name} (${alphabetIds[index]})`
|
|
||||||
: 'Display name'
|
|
||||||
}
|
|
||||||
defaultValue={event.displayName}
|
|
||||||
onChange={(e) => {
|
|
||||||
dispatchChangeEvent({
|
|
||||||
...event,
|
|
||||||
displayName: e.target.value,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<ReportEventMore onClick={handleMore(event)} />
|
<ReportEventMore onClick={handleMore(event)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Segment and Filter buttons */}
|
{/* Segment and Filter buttons */}
|
||||||
<div className="flex gap-2 p-2 pt-0 ">
|
{(showSegment || showAddFilter) && (
|
||||||
<DropdownMenuComposed
|
<div className="flex gap-2 p-2 pt-0 ">
|
||||||
onChange={(segment) => {
|
{showSegment && (
|
||||||
dispatch(
|
<DropdownMenuComposed
|
||||||
changeEvent({
|
onChange={(segment) => {
|
||||||
...event,
|
dispatch(
|
||||||
segment,
|
changeEvent({
|
||||||
}),
|
...event,
|
||||||
);
|
segment,
|
||||||
}}
|
}),
|
||||||
items={[
|
);
|
||||||
{
|
}}
|
||||||
value: 'event',
|
items={[
|
||||||
label: 'All events',
|
{
|
||||||
},
|
value: 'event',
|
||||||
{
|
label: 'All events',
|
||||||
value: 'user',
|
},
|
||||||
label: 'Unique users',
|
{
|
||||||
},
|
value: 'user',
|
||||||
{
|
label: 'Unique users',
|
||||||
value: 'session',
|
},
|
||||||
label: 'Unique sessions',
|
{
|
||||||
},
|
value: 'session',
|
||||||
{
|
label: 'Unique sessions',
|
||||||
value: 'user_average',
|
},
|
||||||
label: 'Average event per user',
|
{
|
||||||
},
|
value: 'user_average',
|
||||||
{
|
label: 'Average event per user',
|
||||||
value: 'one_event_per_user',
|
},
|
||||||
label: 'One event per user',
|
{
|
||||||
},
|
value: 'one_event_per_user',
|
||||||
{
|
label: 'One event per user',
|
||||||
value: 'property_sum',
|
},
|
||||||
label: 'Sum of property',
|
{
|
||||||
},
|
value: 'property_sum',
|
||||||
{
|
label: 'Sum of property',
|
||||||
value: 'property_average',
|
},
|
||||||
label: 'Average of property',
|
{
|
||||||
},
|
value: 'property_average',
|
||||||
]}
|
label: 'Average of property',
|
||||||
label="Segment"
|
},
|
||||||
>
|
]}
|
||||||
<button
|
label="Segment"
|
||||||
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"
|
<button
|
||||||
>
|
type="button"
|
||||||
{event.segment === 'user' ? (
|
className="flex items-center gap-1 rounded-md border border-border bg-card p-1 px-2 text-sm font-medium leading-none"
|
||||||
<>
|
>
|
||||||
<Users size={12} /> Unique users
|
{event.segment === 'user' ? (
|
||||||
</>
|
<>
|
||||||
) : event.segment === 'session' ? (
|
<Users size={12} /> Unique users
|
||||||
<>
|
</>
|
||||||
<Users size={12} /> Unique sessions
|
) : event.segment === 'session' ? (
|
||||||
</>
|
<>
|
||||||
) : event.segment === 'user_average' ? (
|
<Users size={12} /> Unique sessions
|
||||||
<>
|
</>
|
||||||
<Users size={12} /> Average event per user
|
) : event.segment === 'user_average' ? (
|
||||||
</>
|
<>
|
||||||
) : event.segment === 'one_event_per_user' ? (
|
<Users size={12} /> Average event per user
|
||||||
<>
|
</>
|
||||||
<Users size={12} /> One event per user
|
) : event.segment === 'one_event_per_user' ? (
|
||||||
</>
|
<>
|
||||||
) : event.segment === 'property_sum' ? (
|
<Users size={12} /> One event per user
|
||||||
<>
|
</>
|
||||||
<Users size={12} /> Sum of property
|
) : event.segment === 'property_sum' ? (
|
||||||
</>
|
<>
|
||||||
) : event.segment === 'property_average' ? (
|
<Users size={12} /> Sum of property
|
||||||
<>
|
</>
|
||||||
<Users size={12} /> Average of property
|
) : event.segment === 'property_average' ? (
|
||||||
</>
|
<>
|
||||||
) : (
|
<Users size={12} /> Average of property
|
||||||
<>
|
</>
|
||||||
<GanttChart size={12} /> All events
|
) : (
|
||||||
</>
|
<>
|
||||||
)}
|
<GanttChart size={12} /> All events
|
||||||
</button>
|
</>
|
||||||
</DropdownMenuComposed>
|
)}
|
||||||
{/* */}
|
</button>
|
||||||
<FiltersCombobox event={event} />
|
</DropdownMenuComposed>
|
||||||
|
)}
|
||||||
|
{/* */}
|
||||||
|
{showAddFilter && <FiltersCombobox event={event} />}
|
||||||
|
|
||||||
{(event.segment === 'property_average' ||
|
{showSegment &&
|
||||||
event.segment === 'property_sum') && (
|
(event.segment === 'property_average' ||
|
||||||
<EventPropertiesCombobox event={event} />
|
event.segment === 'property_sum') && (
|
||||||
)}
|
<EventPropertiesCombobox event={event} />
|
||||||
</div>
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<FiltersList event={event} />
|
{!isSelectManyEvents && <FiltersList event={event} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
<Combobox
|
<Combobox
|
||||||
|
disabled={isAddEventDisabled}
|
||||||
icon={GanttChartIcon}
|
icon={GanttChartIcon}
|
||||||
value={''}
|
value={''}
|
||||||
searchable
|
searchable
|
||||||
@@ -218,17 +246,6 @@ export function ReportEvents() {
|
|||||||
placeholder="Select event"
|
placeholder="Select event"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<label
|
|
||||||
className="mt-4 flex cursor-pointer select-none items-center gap-2 font-medium"
|
|
||||||
htmlFor="previous"
|
|
||||||
>
|
|
||||||
<Checkbox
|
|
||||||
id="previous"
|
|
||||||
checked={previous}
|
|
||||||
onCheckedChange={(val) => dispatch(changePrevious(!!val))}
|
|
||||||
/>
|
|
||||||
Show previous / Compare
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Combobox } from '@/components/ui/combobox';
|
||||||
|
import { useDispatch, useSelector } from '@/redux';
|
||||||
|
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { changeCriteria, changePrevious, 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 dispatch = useDispatch();
|
||||||
|
|
||||||
|
const fields = useMemo(() => {
|
||||||
|
const fields = [];
|
||||||
|
|
||||||
|
if (chartType !== 'retention') {
|
||||||
|
fields.push('previous');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chartType === 'retention') {
|
||||||
|
fields.push('criteria');
|
||||||
|
fields.push('unit');
|
||||||
|
}
|
||||||
|
|
||||||
|
return fields;
|
||||||
|
}, [chartType]);
|
||||||
|
|
||||||
|
if (fields.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h3 className="mb-2 font-medium">Settings</h3>
|
||||||
|
<div className="col rounded-lg border bg-def-100 p-4 gap-2">
|
||||||
|
{fields.includes('previous') && (
|
||||||
|
<Label className="flex items-center justify-between mb-0">
|
||||||
|
<span>Compare to previous period</span>
|
||||||
|
<Switch
|
||||||
|
checked={previous}
|
||||||
|
onCheckedChange={(val) => dispatch(changePrevious(!!val))}
|
||||||
|
/>
|
||||||
|
</Label>
|
||||||
|
)}
|
||||||
|
{fields.includes('criteria') && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span>Criteria</span>
|
||||||
|
<Combobox
|
||||||
|
align="end"
|
||||||
|
placeholder="Select criteria"
|
||||||
|
value={criteria}
|
||||||
|
onChange={(val) => dispatch(changeCriteria(val))}
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
label: 'On or After',
|
||||||
|
value: 'on_or_after',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'On',
|
||||||
|
value: 'on',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{fields.includes('unit') && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span>Unit</span>
|
||||||
|
<Combobox
|
||||||
|
align="end"
|
||||||
|
placeholder="Unit"
|
||||||
|
value={unit || 'count'}
|
||||||
|
onChange={(val) => {
|
||||||
|
dispatch(changeUnit(val === 'count' ? undefined : val));
|
||||||
|
}}
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
label: 'Count',
|
||||||
|
value: 'count',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '%',
|
||||||
|
value: '%',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,15 +5,17 @@ import { useSelector } from '@/redux';
|
|||||||
import { ReportBreakdowns } from './ReportBreakdowns';
|
import { ReportBreakdowns } from './ReportBreakdowns';
|
||||||
import { ReportEvents } from './ReportEvents';
|
import { ReportEvents } from './ReportEvents';
|
||||||
import { ReportFormula } from './ReportFormula';
|
import { ReportFormula } from './ReportFormula';
|
||||||
|
import { ReportSettings } from './ReportSettings';
|
||||||
|
|
||||||
export function ReportSidebar() {
|
export function ReportSidebar() {
|
||||||
const { chartType } = useSelector((state) => state.report);
|
const { chartType } = useSelector((state) => state.report);
|
||||||
const showFormula = chartType !== 'funnel';
|
const showFormula = chartType !== 'funnel' && chartType !== 'retention';
|
||||||
const showBreakdown = chartType !== 'funnel';
|
const showBreakdown = chartType !== 'funnel' && chartType !== 'retention';
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col gap-8">
|
<div className="flex flex-col gap-8">
|
||||||
<ReportEvents />
|
<ReportEvents />
|
||||||
|
<ReportSettings />
|
||||||
{showFormula && <ReportFormula />}
|
{showFormula && <ReportFormula />}
|
||||||
{showBreakdown && <ReportBreakdowns />}
|
{showBreakdown && <ReportBreakdowns />}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -34,10 +34,6 @@ interface FilterProps {
|
|||||||
interface PureFilterProps {
|
interface PureFilterProps {
|
||||||
eventName: string;
|
eventName: string;
|
||||||
filter: IChartEventFilter;
|
filter: IChartEventFilter;
|
||||||
range: IChartRange;
|
|
||||||
startDate: string | null;
|
|
||||||
endDate: string | null;
|
|
||||||
interval: IInterval;
|
|
||||||
onRemove: (filter: IChartEventFilter) => void;
|
onRemove: (filter: IChartEventFilter) => void;
|
||||||
onChangeValue: (
|
onChangeValue: (
|
||||||
value: IChartEventFilterValue[],
|
value: IChartEventFilterValue[],
|
||||||
@@ -111,10 +107,6 @@ export function FilterItem({ filter, event }: FilterProps) {
|
|||||||
<PureFilterItem
|
<PureFilterItem
|
||||||
filter={filter}
|
filter={filter}
|
||||||
eventName={event.name}
|
eventName={event.name}
|
||||||
range={range}
|
|
||||||
startDate={startDate}
|
|
||||||
endDate={endDate}
|
|
||||||
interval={interval}
|
|
||||||
onRemove={onRemove}
|
onRemove={onRemove}
|
||||||
onChangeValue={onChangeValue}
|
onChangeValue={onChangeValue}
|
||||||
onChangeOperator={onChangeOperator}
|
onChangeOperator={onChangeOperator}
|
||||||
@@ -126,10 +118,6 @@ export function FilterItem({ filter, event }: FilterProps) {
|
|||||||
export function PureFilterItem({
|
export function PureFilterItem({
|
||||||
filter,
|
filter,
|
||||||
eventName,
|
eventName,
|
||||||
range,
|
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
interval,
|
|
||||||
onRemove,
|
onRemove,
|
||||||
onChangeValue,
|
onChangeValue,
|
||||||
onChangeOperator,
|
onChangeOperator,
|
||||||
@@ -142,10 +130,6 @@ export function PureFilterItem({
|
|||||||
event: eventName,
|
event: eventName,
|
||||||
property: filter.name,
|
property: filter.name,
|
||||||
projectId,
|
projectId,
|
||||||
range,
|
|
||||||
interval,
|
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const valuesCombobox =
|
const valuesCombobox =
|
||||||
@@ -188,11 +172,7 @@ export function PureFilterItem({
|
|||||||
}))}
|
}))}
|
||||||
label="Operator"
|
label="Operator"
|
||||||
>
|
>
|
||||||
<Button
|
<Button variant={'outline'} className="whitespace-nowrap">
|
||||||
variant={'outline'}
|
|
||||||
className="whitespace-nowrap"
|
|
||||||
size="default"
|
|
||||||
>
|
|
||||||
{operators[filter.operator]}
|
{operators[filter.operator]}
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuComposed>
|
</DropdownMenuComposed>
|
||||||
|
|||||||
@@ -15,20 +15,12 @@ interface FiltersComboboxProps {
|
|||||||
|
|
||||||
export function FiltersCombobox({ event }: FiltersComboboxProps) {
|
export function FiltersCombobox({ event }: FiltersComboboxProps) {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const interval = useSelector((state) => state.report.interval);
|
|
||||||
const range = useSelector((state) => state.report.range);
|
|
||||||
const startDate = useSelector((state) => state.report.startDate);
|
|
||||||
const endDate = useSelector((state) => state.report.endDate);
|
|
||||||
const { projectId } = useAppParams();
|
const { projectId } = useAppParams();
|
||||||
|
|
||||||
const properties = useEventProperties(
|
const properties = useEventProperties(
|
||||||
{
|
{
|
||||||
event: event.name,
|
event: event.name,
|
||||||
projectId,
|
projectId,
|
||||||
range,
|
|
||||||
interval,
|
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
enabled: !!event.name,
|
enabled: !!event.name,
|
||||||
|
|||||||
@@ -45,6 +45,26 @@ export interface ButtonProps
|
|||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
icon?: LucideIcon;
|
icon?: LucideIcon;
|
||||||
responsive?: boolean;
|
responsive?: boolean;
|
||||||
|
autoHeight?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fixHeight({
|
||||||
|
autoHeight,
|
||||||
|
size,
|
||||||
|
}: { autoHeight?: boolean; size: ButtonProps['size'] }) {
|
||||||
|
if (autoHeight) {
|
||||||
|
switch (size) {
|
||||||
|
case 'lg':
|
||||||
|
return 'h-auto min-h-11 py-2';
|
||||||
|
case 'icon':
|
||||||
|
return 'h-auto min-h-8 py-1';
|
||||||
|
case 'default':
|
||||||
|
return 'h-auto min-h-10 py-2';
|
||||||
|
default:
|
||||||
|
return 'h-auto min-h-8 py-1';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
@@ -59,6 +79,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|||||||
disabled,
|
disabled,
|
||||||
icon,
|
icon,
|
||||||
responsive,
|
responsive,
|
||||||
|
autoHeight,
|
||||||
...props
|
...props
|
||||||
},
|
},
|
||||||
ref,
|
ref,
|
||||||
@@ -67,7 +88,10 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|||||||
const Icon = loading ? Loader2 : (icon ?? null);
|
const Icon = loading ? Loader2 : (icon ?? null);
|
||||||
return (
|
return (
|
||||||
<Comp
|
<Comp
|
||||||
className={cn(buttonVariants({ variant, size, className }))}
|
className={cn(
|
||||||
|
buttonVariants({ variant, size, className }),
|
||||||
|
fixHeight({ autoHeight, size }),
|
||||||
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
disabled={loading || disabled}
|
disabled={loading || disabled}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import VirtualList from 'rc-virtual-list';
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { useOnClickOutside } from 'usehooks-ts';
|
import { useOnClickOutside } from 'usehooks-ts';
|
||||||
|
|
||||||
import { Button } from './button';
|
import { Button, type ButtonProps } from './button';
|
||||||
import { Checkbox, DumpCheckbox } from './checkbox';
|
import { Checkbox, DumpCheckbox } from './checkbox';
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from './popover';
|
import { Popover, PopoverContent, PopoverTrigger } from './popover';
|
||||||
|
|
||||||
@@ -19,6 +19,7 @@ interface ComboboxAdvancedProps {
|
|||||||
items: IItem[];
|
items: IItem[];
|
||||||
placeholder: string;
|
placeholder: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
size?: ButtonProps['size'];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ComboboxAdvanced({
|
export function ComboboxAdvanced({
|
||||||
@@ -27,6 +28,7 @@ export function ComboboxAdvanced({
|
|||||||
onChange,
|
onChange,
|
||||||
placeholder,
|
placeholder,
|
||||||
className,
|
className,
|
||||||
|
size,
|
||||||
}: ComboboxAdvancedProps) {
|
}: ComboboxAdvancedProps) {
|
||||||
const [open, setOpen] = React.useState(false);
|
const [open, setOpen] = React.useState(false);
|
||||||
const [inputValue, setInputValue] = React.useState('');
|
const [inputValue, setInputValue] = React.useState('');
|
||||||
@@ -99,7 +101,9 @@ export function ComboboxAdvanced({
|
|||||||
<Button
|
<Button
|
||||||
variant={'outline'}
|
variant={'outline'}
|
||||||
onClick={() => setOpen((prev) => !prev)}
|
onClick={() => setOpen((prev) => !prev)}
|
||||||
className={cn('h-auto min-h-10 py-2', className)}
|
className={className}
|
||||||
|
size={size}
|
||||||
|
autoHeight
|
||||||
>
|
>
|
||||||
<div className="flex w-full flex-wrap gap-1">
|
<div className="flex w-full flex-wrap gap-1">
|
||||||
{value.length === 0 && placeholder}
|
{value.length === 0 && placeholder}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export interface ComboboxProps<T> {
|
|||||||
align?: 'start' | 'end' | 'center';
|
align?: 'start' | 'end' | 'center';
|
||||||
portal?: boolean;
|
portal?: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ExtendedComboboxProps<T> = Omit<
|
export type ExtendedComboboxProps<T> = Omit<
|
||||||
@@ -61,6 +62,7 @@ export function Combobox<T extends string>({
|
|||||||
align = 'start',
|
align = 'start',
|
||||||
portal,
|
portal,
|
||||||
error,
|
error,
|
||||||
|
disabled,
|
||||||
}: ComboboxProps<T>) {
|
}: ComboboxProps<T>) {
|
||||||
const [open, setOpen] = React.useState(false);
|
const [open, setOpen] = React.useState(false);
|
||||||
const [search, setSearch] = React.useState('');
|
const [search, setSearch] = React.useState('');
|
||||||
@@ -75,6 +77,7 @@ export function Combobox<T extends string>({
|
|||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
{children ?? (
|
{children ?? (
|
||||||
<Button
|
<Button
|
||||||
|
disabled={disabled}
|
||||||
size={size}
|
size={size}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
role="combobox"
|
role="combobox"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { cn } from '@/utils/cn';
|
|||||||
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
|
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
|
||||||
import { Check, ChevronRight, Circle } from 'lucide-react';
|
import { Check, ChevronRight, Circle } from 'lucide-react';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
import { ButtonProps } from './button';
|
||||||
|
|
||||||
const DropdownMenu = DropdownMenuPrimitive.Root;
|
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ interface TooltiperProps {
|
|||||||
content: React.ReactNode;
|
content: React.ReactNode;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
tooltipClassName?: string;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
side?: 'top' | 'right' | 'bottom' | 'left';
|
side?: 'top' | 'right' | 'bottom' | 'left';
|
||||||
delayDuration?: number;
|
delayDuration?: number;
|
||||||
@@ -49,6 +50,7 @@ export function Tooltiper({
|
|||||||
content,
|
content,
|
||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
|
tooltipClassName,
|
||||||
onClick,
|
onClick,
|
||||||
side,
|
side,
|
||||||
delayDuration = 0,
|
delayDuration = 0,
|
||||||
@@ -62,7 +64,11 @@ export function Tooltiper({
|
|||||||
{children}
|
{children}
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipPortal>
|
<TooltipPortal>
|
||||||
<TooltipContent sideOffset={sideOffset} side={side}>
|
<TooltipContent
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
side={side}
|
||||||
|
className={tooltipClassName}
|
||||||
|
>
|
||||||
{content}
|
{content}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</TooltipPortal>
|
</TooltipPortal>
|
||||||
|
|||||||
@@ -13,9 +13,7 @@ export const formatNumber =
|
|||||||
if (isNil(value)) {
|
if (isNil(value)) {
|
||||||
return 'N/A';
|
return 'N/A';
|
||||||
}
|
}
|
||||||
return new Intl.NumberFormat(locale, {
|
return new Intl.NumberFormat(locale).format(value);
|
||||||
maximumSignificantDigits: 3,
|
|
||||||
}).format(value);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const shortNumber =
|
export const shortNumber =
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
import { type RouterOutputs, api } from '@/trpc/client';
|
import { type RouterOutputs, api } from '@/trpc/client';
|
||||||
|
|
||||||
import { SheetContent } from '@/components/ui/sheet';
|
import { SheetContent } from '@/components/ui/sheet';
|
||||||
import type { NotificationRule } from '@openpanel/db';
|
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { getQueryKey } from '@trpc/react-query';
|
import { getQueryKey } from '@trpc/react-query';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
@@ -11,7 +10,6 @@ import { popModal } from '.';
|
|||||||
import { ModalHeader } from './Modal/Container';
|
import { ModalHeader } from './Modal/Container';
|
||||||
|
|
||||||
import { ColorSquare } from '@/components/color-square';
|
import { ColorSquare } from '@/components/color-square';
|
||||||
import { CheckboxItem } from '@/components/forms/checkbox-item';
|
|
||||||
import { InputWithLabel, WithLabel } from '@/components/forms/input-with-label';
|
import { InputWithLabel, WithLabel } from '@/components/forms/input-with-label';
|
||||||
import { PureFilterItem } from '@/components/report/sidebar/filters/FilterItem';
|
import { PureFilterItem } from '@/components/report/sidebar/filters/FilterItem';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -22,19 +20,8 @@ import { useEventNames } from '@/hooks/useEventNames';
|
|||||||
import { useEventProperties } from '@/hooks/useEventProperties';
|
import { useEventProperties } from '@/hooks/useEventProperties';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { shortId } from '@openpanel/common';
|
import { shortId } from '@openpanel/common';
|
||||||
import {
|
import { zCreateNotificationRule } from '@openpanel/validation';
|
||||||
IChartEvent,
|
import { FilterIcon, PlusIcon, SaveIcon, TrashIcon } from 'lucide-react';
|
||||||
type IChartRange,
|
|
||||||
type IInterval,
|
|
||||||
zCreateNotificationRule,
|
|
||||||
} from '@openpanel/validation';
|
|
||||||
import {
|
|
||||||
FilterIcon,
|
|
||||||
PlusIcon,
|
|
||||||
SaveIcon,
|
|
||||||
SmartphoneIcon,
|
|
||||||
TrashIcon,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
type SubmitHandler,
|
type SubmitHandler,
|
||||||
@@ -200,9 +187,6 @@ export default function AddNotificationRule({ rule }: Props) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const interval: IInterval = 'day';
|
|
||||||
const range: IChartRange = 'lastMonth';
|
|
||||||
|
|
||||||
function EventField({
|
function EventField({
|
||||||
form,
|
form,
|
||||||
index,
|
index,
|
||||||
@@ -213,7 +197,7 @@ function EventField({
|
|||||||
remove: () => void;
|
remove: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { projectId } = useAppParams();
|
const { projectId } = useAppParams();
|
||||||
const eventNames = useEventNames({ projectId, interval, range });
|
const eventNames = useEventNames({ projectId });
|
||||||
const filtersArray = useFieldArray({
|
const filtersArray = useFieldArray({
|
||||||
control: form.control,
|
control: form.control,
|
||||||
name: `config.events.${index}.filters`,
|
name: `config.events.${index}.filters`,
|
||||||
@@ -222,7 +206,7 @@ function EventField({
|
|||||||
control: form.control,
|
control: form.control,
|
||||||
name: `config.events.${index}.name`,
|
name: `config.events.${index}.name`,
|
||||||
});
|
});
|
||||||
const properties = useEventProperties({ projectId, interval, range });
|
const properties = useEventProperties({ projectId });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border bg-def-100 rounded">
|
<div className="border bg-def-100 rounded">
|
||||||
@@ -280,10 +264,6 @@ function EventField({
|
|||||||
<PureFilterItem
|
<PureFilterItem
|
||||||
eventName={eventName}
|
eventName={eventName}
|
||||||
filter={filter}
|
filter={filter}
|
||||||
range={range}
|
|
||||||
startDate={null}
|
|
||||||
endDate={null}
|
|
||||||
interval={interval}
|
|
||||||
onRemove={() => {
|
onRemove={() => {
|
||||||
filtersArray.remove(index);
|
filtersArray.remove(index);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
# Export API
|
# Export API
|
||||||
|
|
||||||
|
The Export API allows you to retrieve event data and chart data from your OpenPanel projects.
|
||||||
|
|
||||||
## Authentication
|
## Authentication
|
||||||
|
|
||||||
To authenticate with the Export API, you need to use your `clientId` and `clientSecret`. Make sure your client has `read` or `root` mode. The default client does not have access to the Export API.
|
To authenticate with the Export API, you need to use your `clientId` and `clientSecret`. Make sure your client has `read` or `root` mode. The default client does not have access to the Export API.
|
||||||
|
|
||||||
We expect you to send `openpanel-client-id` and `openpanel-client-secret` headers with your requests.
|
Include the following headers with your requests:
|
||||||
|
- `openpanel-client-id`: Your OpenPanel client ID
|
||||||
|
- `openpanel-client-secret`: Your OpenPanel client secret
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
|
||||||
@@ -37,6 +41,136 @@ curl 'https://api.openpanel.dev/export/events?project_id=abc&event=screen_view&s
|
|||||||
-H 'openpanel-client-secret: YOUR_CLIENT_SECRET'
|
-H 'openpanel-client-secret: YOUR_CLIENT_SECRET'
|
||||||
```
|
```
|
||||||
|
|
||||||
## Profiles
|
### Query Parameters
|
||||||
|
|
||||||
During development
|
| Parameter | Type | Description | Example |
|
||||||
|
|-----------|------|-------------|---------|
|
||||||
|
| projectId | string | The ID of the project to fetch events from | `abc123` |
|
||||||
|
| event | string or string[] | Event name(s) to filter | `screen_view` or `["screen_view","button_click"]` |
|
||||||
|
| start | string | Start date for the event range (ISO format) | `2024-04-15` |
|
||||||
|
| end | string | End date for the event range (ISO format) | `2024-04-18` |
|
||||||
|
| page | number | Page number for pagination (default: 1) | `2` |
|
||||||
|
| limit | number | Number of events per page (default: 50, max: 50) | `25` |
|
||||||
|
| includes | string or string[] | Additional fields to include in the response | `profile` or `["profile","meta"]` |
|
||||||
|
|
||||||
|
### Example Request
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl 'https://api.openpanel.dev/export/events?project_id=abc123&event=screen_view&start=2024-04-15&end=2024-04-18&page=1&limit=50&includes=profile,meta' \
|
||||||
|
-H 'openpanel-client-id: YOUR_CLIENT_ID' \
|
||||||
|
-H 'openpanel-client-secret: YOUR_CLIENT_SECRET'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"meta": {
|
||||||
|
"count": number,
|
||||||
|
"totalCount": number,
|
||||||
|
"pages": number,
|
||||||
|
"current": number
|
||||||
|
},
|
||||||
|
"data": Array<Event>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Charts
|
||||||
|
|
||||||
|
Retrieve chart data for a specific project.
|
||||||
|
|
||||||
|
### Endpoint
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /export/charts
|
||||||
|
```
|
||||||
|
|
||||||
|
### Query Parameters
|
||||||
|
|
||||||
|
| Parameter | Type | Description | Example |
|
||||||
|
|-----------|------|-------------|---------|
|
||||||
|
| projectId | string | The ID of the project to fetch chart data from | `abc123` |
|
||||||
|
| events | string[] | Array of event names to include in the chart | `["sign_up","purchase"]` |
|
||||||
|
| breakdowns | object[] | Array of breakdown configurations | `[{"name":"country"}]` |
|
||||||
|
| interval | string | Time interval for data points | `day` |
|
||||||
|
| range | string | Predefined date range | `last_7_days` |
|
||||||
|
| previous | boolean | Include data from the previous period | `true` |
|
||||||
|
| startDate | string | Custom start date (ISO format) | `2024-04-01` |
|
||||||
|
| endDate | string | Custom end date (ISO format) | `2024-04-30` |
|
||||||
|
| chartType | string | Type of chart to generate | `linear` |
|
||||||
|
| metric | string | Metric to use for calculations | `sum` |
|
||||||
|
| limit | number | Limit the number of results | `10` |
|
||||||
|
| offset | number | Offset for pagination | `0` |
|
||||||
|
|
||||||
|
### Example Request
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl 'https://api.openpanel.dev/export/charts?projectId=abc123&events=["sign_up","purchase"]&interval=day&range=last_30_days&chartType=linear&metric=sum' \
|
||||||
|
-H 'openpanel-client-id: YOUR_CLIENT_ID' \
|
||||||
|
-H 'openpanel-client-secret: YOUR_CLIENT_SECRET'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response
|
||||||
|
|
||||||
|
The response will include chart data with series, metrics, and optional previous period comparisons based on the input parameters.
|
||||||
|
|
||||||
|
## Funnel
|
||||||
|
|
||||||
|
Retrieve funnel data for a specific project.
|
||||||
|
|
||||||
|
### Endpoint
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /export/funnel
|
||||||
|
```
|
||||||
|
|
||||||
|
### Query Parameters
|
||||||
|
|
||||||
|
| Parameter | Type | Description | Example |
|
||||||
|
|-----------|------|-------------|---------|
|
||||||
|
| projectId | string | The ID of the project to fetch funnel data from | `abc123` |
|
||||||
|
| events | object[] | Array of event configurations for the funnel steps | `[{"name":"sign_up","filters":[]}]` |
|
||||||
|
| range | string | Predefined date range | `last_30_days` |
|
||||||
|
| startDate | string | Custom start date (ISO format) | `2024-04-01` |
|
||||||
|
| endDate | string | Custom end date (ISO format) | `2024-04-30` |
|
||||||
|
|
||||||
|
### Example Request
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl 'https://api.openpanel.dev/export/funnel?projectId=abc123&events=[{"name":"sign_up"},{"name":"purchase"}]&range=last_30_days' \
|
||||||
|
-H 'openpanel-client-id: YOUR_CLIENT_ID' \
|
||||||
|
-H 'openpanel-client-secret: YOUR_CLIENT_SECRET'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response
|
||||||
|
|
||||||
|
The response will include funnel data with total sessions and step-by-step breakdown of the funnel progression.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"totalSessions": number,
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"event": {
|
||||||
|
"name": string,
|
||||||
|
"displayName": string
|
||||||
|
},
|
||||||
|
"count": number,
|
||||||
|
"percent": number,
|
||||||
|
"dropoffCount": number,
|
||||||
|
"dropoffPercent": number,
|
||||||
|
"previousCount": number
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- All date parameters should be in ISO format (YYYY-MM-DD).
|
||||||
|
- The `range` parameter accepts values like `today`, `yesterday`, `last_7_days`, `last_30_days`, `this_month`, `last_month`, `this_year`, `last_year`, `all_time`.
|
||||||
|
- The `interval` parameter accepts values like `minute`, `hour`, `day`, `month`.
|
||||||
|
- The `chartType` parameter can be `linear` or other supported chart types.
|
||||||
|
- The `metric` parameter can be `sum`, `average`, `min`, or `max`.
|
||||||
|
|
||||||
|
Remember to replace `YOUR_CLIENT_ID` and `YOUR_CLIENT_SECRET` with your actual OpenPanel API credentials.
|
||||||
@@ -30,7 +30,8 @@
|
|||||||
"noNonNullAssertion": "off"
|
"noNonNullAssertion": "off"
|
||||||
},
|
},
|
||||||
"correctness": {
|
"correctness": {
|
||||||
"useExhaustiveDependencies": "off"
|
"useExhaustiveDependencies": "off",
|
||||||
|
"noUnreachable": "off"
|
||||||
},
|
},
|
||||||
"performance": {
|
"performance": {
|
||||||
"noDelete": "off",
|
"noDelete": "off",
|
||||||
|
|||||||
@@ -32,6 +32,19 @@ export function getTimezoneFromDateString(_date: string) {
|
|||||||
'-10:00': 'Pacific/Honolulu',
|
'-10:00': 'Pacific/Honolulu',
|
||||||
'-11:00': 'Pacific/Midway',
|
'-11:00': 'Pacific/Midway',
|
||||||
'-12:00': 'Pacific/Tarawa',
|
'-12:00': 'Pacific/Tarawa',
|
||||||
|
// Additional time zones
|
||||||
|
'+05:30': 'Asia/Kolkata',
|
||||||
|
'+05:45': 'Asia/Kathmandu',
|
||||||
|
'+08:45': 'Australia/Eucla',
|
||||||
|
'+09:30': 'Australia/Darwin',
|
||||||
|
'+10:30': 'Australia/Adelaide',
|
||||||
|
'+12:45': 'Pacific/Chatham',
|
||||||
|
'+13:00': 'Pacific/Apia',
|
||||||
|
'+14:00': 'Pacific/Kiritimati',
|
||||||
|
'-02:30': 'America/St_Johns',
|
||||||
|
'-03:30': 'America/St_Johns',
|
||||||
|
'-04:30': 'America/Caracas',
|
||||||
|
'-09:30': 'Pacific/Marquesas',
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultTimezone = 'UTC';
|
const defaultTimezone = 'UTC';
|
||||||
|
|||||||
@@ -5,10 +5,13 @@ export const round = (num: number, decimals = 2) => {
|
|||||||
return Math.round((num + Number.EPSILON) * factor) / factor;
|
return Math.round((num + Number.EPSILON) * factor) / factor;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const average = (arr: (number | null)[]) => {
|
export const average = (arr: (number | null)[], includeZero = false) => {
|
||||||
const filtered = arr.filter(
|
const filtered = arr.filter(
|
||||||
(n): n is number =>
|
(n): n is number =>
|
||||||
isNumber(n) && !Number.isNaN(n) && Number.isFinite(n) && n !== 0,
|
isNumber(n) &&
|
||||||
|
!Number.isNaN(n) &&
|
||||||
|
Number.isFinite(n) &&
|
||||||
|
(includeZero || n !== 0),
|
||||||
);
|
);
|
||||||
const avg = filtered.reduce((p, c) => p + c, 0) / filtered.length;
|
const avg = filtered.reduce((p, c) => p + c, 0) / filtered.length;
|
||||||
return Number.isNaN(avg) ? 0 : avg;
|
return Number.isNaN(avg) ? 0 : avg;
|
||||||
@@ -17,10 +20,10 @@ export const average = (arr: (number | null)[]) => {
|
|||||||
export const sum = (arr: (number | null | undefined)[]): number =>
|
export const sum = (arr: (number | null | undefined)[]): number =>
|
||||||
round(arr.filter(isNumber).reduce((acc, item) => acc + item, 0));
|
round(arr.filter(isNumber).reduce((acc, item) => acc + item, 0));
|
||||||
|
|
||||||
export const min = (arr: (number | null)[]): number =>
|
export const min = (arr: (number | null | undefined)[]): number =>
|
||||||
Math.min(...arr.filter(isNumber));
|
Math.min(...arr.filter(isNumber));
|
||||||
|
|
||||||
export const max = (arr: (number | null)[]): number =>
|
export const max = (arr: (number | null | undefined)[]): number =>
|
||||||
Math.max(...arr.filter(isNumber));
|
Math.max(...arr.filter(isNumber));
|
||||||
|
|
||||||
export const isFloat = (n: number) => n % 1 !== 0;
|
export const isFloat = (n: number) => n % 1 !== 0;
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ export const intervals = {
|
|||||||
minute: 'minute',
|
minute: 'minute',
|
||||||
day: 'day',
|
day: 'day',
|
||||||
hour: 'hour',
|
hour: 'hour',
|
||||||
|
week: 'week',
|
||||||
month: 'month',
|
month: 'month',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|||||||
58
packages/db/migrations/20241007210706_retention_mv.sql
Normal file
58
packages/db/migrations/20241007210706_retention_mv.sql
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- +goose StatementBegin
|
||||||
|
CREATE MATERIALIZED VIEW cohort_events_mv ENGINE = AggregatingMergeTree()
|
||||||
|
ORDER BY (project_id, name, created_at, profile_id) POPULATE AS
|
||||||
|
SELECT project_id,
|
||||||
|
name,
|
||||||
|
toDate(created_at) AS created_at,
|
||||||
|
profile_id,
|
||||||
|
COUNT() AS event_count
|
||||||
|
FROM events_v2
|
||||||
|
WHERE profile_id != device_id
|
||||||
|
GROUP BY project_id,
|
||||||
|
name,
|
||||||
|
created_at,
|
||||||
|
profile_id;
|
||||||
|
-- +goose StatementEnd
|
||||||
|
-- +goose StatementBegin
|
||||||
|
CREATE MATERIALIZED VIEW distinct_event_names_mv ENGINE = AggregatingMergeTree()
|
||||||
|
ORDER BY (project_id, name, created_at) POPULATE AS
|
||||||
|
SELECT project_id,
|
||||||
|
name,
|
||||||
|
max(created_at) AS created_at,
|
||||||
|
count() AS event_count
|
||||||
|
FROM events_v2
|
||||||
|
GROUP BY project_id,
|
||||||
|
name;
|
||||||
|
-- +goose StatementEnd
|
||||||
|
-- +goose StatementBegin
|
||||||
|
CREATE MATERIALIZED VIEW event_property_values_mv ENGINE = AggregatingMergeTree()
|
||||||
|
ORDER BY (project_id, name, property_key, property_value) POPULATE AS
|
||||||
|
select project_id,
|
||||||
|
name,
|
||||||
|
key_value.keys as property_key,
|
||||||
|
key_value.values as property_value,
|
||||||
|
created_at
|
||||||
|
from (
|
||||||
|
SELECT project_id,
|
||||||
|
name,
|
||||||
|
untuple(arrayJoin(properties)) as key_value,
|
||||||
|
max(created_at) as created_at
|
||||||
|
from events_v2
|
||||||
|
group by project_id,
|
||||||
|
name,
|
||||||
|
key_value
|
||||||
|
)
|
||||||
|
where property_value != ''
|
||||||
|
and property_key != ''
|
||||||
|
and property_key NOT IN ('__duration_from', '__properties_from')
|
||||||
|
group by project_id,
|
||||||
|
name,
|
||||||
|
property_key,
|
||||||
|
property_value,
|
||||||
|
created_at;
|
||||||
|
-- +goose StatementEnd
|
||||||
|
-- +goose Down
|
||||||
|
-- +goose StatementBegin
|
||||||
|
SELECT 'down SQL query';
|
||||||
|
-- +goose StatementEnd
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "ChartType" ADD VALUE 'retention';
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "Interval" ADD VALUE 'week';
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "reports" ADD COLUMN "criteria" TEXT;
|
||||||
@@ -192,6 +192,7 @@ enum Interval {
|
|||||||
day
|
day
|
||||||
month
|
month
|
||||||
minute
|
minute
|
||||||
|
week
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ChartType {
|
enum ChartType {
|
||||||
@@ -203,6 +204,7 @@ enum ChartType {
|
|||||||
area
|
area
|
||||||
map
|
map
|
||||||
funnel
|
funnel
|
||||||
|
retention
|
||||||
}
|
}
|
||||||
|
|
||||||
model Dashboard {
|
model Dashboard {
|
||||||
@@ -243,6 +245,7 @@ model Report {
|
|||||||
projectId String
|
projectId String
|
||||||
project Project @relation(fields: [projectId], references: [id])
|
project Project @relation(fields: [projectId], references: [id])
|
||||||
previous Boolean @default(false)
|
previous Boolean @default(false)
|
||||||
|
criteria String?
|
||||||
|
|
||||||
dashboardId String
|
dashboardId String
|
||||||
dashboard Dashboard @relation(fields: [dashboardId], references: [id])
|
dashboard Dashboard @relation(fields: [dashboardId], references: [id])
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ export const TABLE_NAMES = {
|
|||||||
self_hosting: 'self_hosting',
|
self_hosting: 'self_hosting',
|
||||||
events_bots: 'events_bots',
|
events_bots: 'events_bots',
|
||||||
dau_mv: 'dau_mv',
|
dau_mv: 'dau_mv',
|
||||||
|
event_names_mv: 'distinct_event_names_mv',
|
||||||
|
event_property_values_mv: 'event_property_values_mv',
|
||||||
|
cohort_events_mv: 'cohort_events_mv',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const originalCh = createClient({
|
export const originalCh = createClient({
|
||||||
@@ -129,6 +132,10 @@ export function formatClickhouseDate(
|
|||||||
_date: Date | string,
|
_date: Date | string,
|
||||||
skipTime = false,
|
skipTime = false,
|
||||||
): string {
|
): string {
|
||||||
|
if (typeof _date === 'string') {
|
||||||
|
return _date.slice(0, 19).replace('T', ' ');
|
||||||
|
}
|
||||||
|
|
||||||
const date = typeof _date === 'string' ? new Date(_date) : _date;
|
const date = typeof _date === 'string' ? new Date(_date) : _date;
|
||||||
if (skipTime) {
|
if (skipTime) {
|
||||||
return date.toISOString().split('T')[0]!;
|
return date.toISOString().split('T')[0]!;
|
||||||
|
|||||||
@@ -81,6 +81,10 @@ export function getChartSql({
|
|||||||
sb.select.date = `toStartOfDay(toTimeZone(created_at, '${getTimezoneFromDateString(startDate)}')) as date`;
|
sb.select.date = `toStartOfDay(toTimeZone(created_at, '${getTimezoneFromDateString(startDate)}')) as date`;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case 'week': {
|
||||||
|
sb.select.date = `toStartOfWeek(toTimeZone(created_at, '${getTimezoneFromDateString(startDate)}')) as date`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
case 'month': {
|
case 'month': {
|
||||||
sb.select.date = `toStartOfMonth(toTimeZone(created_at, '${getTimezoneFromDateString(startDate)}')) as date`;
|
sb.select.date = `toStartOfMonth(toTimeZone(created_at, '${getTimezoneFromDateString(startDate)}')) as date`;
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type {
|
|||||||
IChartLineType,
|
IChartLineType,
|
||||||
IChartProps,
|
IChartProps,
|
||||||
IChartRange,
|
IChartRange,
|
||||||
|
ICriteria,
|
||||||
} from '@openpanel/validation';
|
} from '@openpanel/validation';
|
||||||
|
|
||||||
import { db } from '../prisma-client';
|
import { db } from '../prisma-client';
|
||||||
@@ -64,6 +65,7 @@ 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 as ICriteria) ?? undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { flatten, map, pipe, prop, sort, uniq } from 'ramda';
|
import { flatten, map, pipe, prop, range, sort, uniq } from 'ramda';
|
||||||
import { escape } from 'sqlstring';
|
import { escape } from 'sqlstring';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
@@ -7,12 +7,23 @@ import {
|
|||||||
chQuery,
|
chQuery,
|
||||||
createSqlBuilder,
|
createSqlBuilder,
|
||||||
db,
|
db,
|
||||||
formatClickhouseDate,
|
|
||||||
getSelectPropertyKey,
|
getSelectPropertyKey,
|
||||||
toDate,
|
toDate,
|
||||||
} from '@openpanel/db';
|
} from '@openpanel/db';
|
||||||
import { zChartInput, zRange, zTimeInterval } from '@openpanel/validation';
|
import {
|
||||||
|
zChartInput,
|
||||||
|
zCriteria,
|
||||||
|
zRange,
|
||||||
|
zTimeInterval,
|
||||||
|
} from '@openpanel/validation';
|
||||||
|
|
||||||
|
import { round } from '@openpanel/common';
|
||||||
|
import {
|
||||||
|
differenceInDays,
|
||||||
|
differenceInMonths,
|
||||||
|
differenceInWeeks,
|
||||||
|
formatISO,
|
||||||
|
} from 'date-fns';
|
||||||
import { getProjectAccessCached } from '../access';
|
import { getProjectAccessCached } from '../access';
|
||||||
import { TRPCAccessError } from '../errors';
|
import { TRPCAccessError } from '../errors';
|
||||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from '../trpc';
|
import { createTRPCRouter, protectedProcedure, publicProcedure } from '../trpc';
|
||||||
@@ -24,21 +35,23 @@ import {
|
|||||||
getFunnelStep,
|
getFunnelStep,
|
||||||
} from './chart.helpers';
|
} from './chart.helpers';
|
||||||
|
|
||||||
|
function utc(date: string | Date) {
|
||||||
|
if (typeof date === 'string') {
|
||||||
|
return date.replace('T', ' ').slice(0, 19);
|
||||||
|
}
|
||||||
|
return formatISO(date).replace('T', ' ').slice(0, 19);
|
||||||
|
}
|
||||||
|
|
||||||
export const chartRouter = createTRPCRouter({
|
export const chartRouter = createTRPCRouter({
|
||||||
events: protectedProcedure
|
events: protectedProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
projectId: z.string(),
|
projectId: z.string(),
|
||||||
range: zRange,
|
|
||||||
interval: zTimeInterval,
|
|
||||||
startDate: z.string().nullish(),
|
|
||||||
endDate: z.string().nullish(),
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.query(async ({ input: { projectId, ...input } }) => {
|
.query(async ({ input: { projectId } }) => {
|
||||||
const { startDate, endDate } = getChartStartEndDate(input);
|
|
||||||
const events = await chQuery<{ name: string }>(
|
const events = await chQuery<{ name: string }>(
|
||||||
`SELECT DISTINCT name FROM ${TABLE_NAMES.events} WHERE project_id = ${escape(projectId)} AND ${toDate('created_at', input.interval)} BETWEEN ${toDate(formatClickhouseDate(startDate), input.interval)} AND ${toDate(formatClickhouseDate(endDate), input.interval)};`,
|
`SELECT DISTINCT name FROM ${TABLE_NAMES.event_names_mv} WHERE project_id = ${escape(projectId)}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@@ -54,23 +67,22 @@ export const chartRouter = createTRPCRouter({
|
|||||||
z.object({
|
z.object({
|
||||||
event: z.string().optional(),
|
event: z.string().optional(),
|
||||||
projectId: z.string(),
|
projectId: z.string(),
|
||||||
range: zRange,
|
|
||||||
interval: zTimeInterval,
|
|
||||||
startDate: z.string().nullish(),
|
|
||||||
endDate: z.string().nullish(),
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.query(async ({ input: { projectId, event, ...input } }) => {
|
.query(async ({ input: { projectId, event } }) => {
|
||||||
const { startDate, endDate } = getChartStartEndDate(input);
|
const res = await chQuery<{ property_key: string; created_at: string }>(
|
||||||
const events = await chQuery<{ keys: string[] }>(
|
`SELECT
|
||||||
`SELECT distinct mapKeys(properties) as keys from ${TABLE_NAMES.events} where ${
|
distinct property_key,
|
||||||
event && event !== '*' ? `name = ${escape(event)} AND ` : ''
|
max(created_at) as created_at
|
||||||
} project_id = ${escape(projectId)} AND
|
FROM ${TABLE_NAMES.event_property_values_mv}
|
||||||
${toDate('created_at', input.interval)} BETWEEN ${toDate(formatClickhouseDate(startDate), input.interval)} AND ${toDate(formatClickhouseDate(endDate), input.interval)};`,
|
WHERE project_id = ${escape(projectId)}
|
||||||
|
${event && event !== '*' ? `AND name = ${escape(event)}` : ''}
|
||||||
|
GROUP BY property_key
|
||||||
|
ORDER BY created_at DESC`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const properties = events
|
const properties = res
|
||||||
.flatMap((event) => event.keys)
|
.map((item) => item.property_key)
|
||||||
.map((item) => item.replace(/\.([0-9]+)\./g, '.*.'))
|
.map((item) => item.replace(/\.([0-9]+)\./g, '.*.'))
|
||||||
.map((item) => item.replace(/\.([0-9]+)/g, '[*]'))
|
.map((item) => item.replace(/\.([0-9]+)/g, '[*]'))
|
||||||
.map((item) => `properties.${item}`);
|
.map((item) => `properties.${item}`);
|
||||||
@@ -108,36 +120,55 @@ export const chartRouter = createTRPCRouter({
|
|||||||
event: z.string(),
|
event: z.string(),
|
||||||
property: z.string(),
|
property: z.string(),
|
||||||
projectId: z.string(),
|
projectId: z.string(),
|
||||||
range: zRange,
|
|
||||||
interval: zTimeInterval,
|
|
||||||
startDate: z.string().nullish(),
|
|
||||||
endDate: z.string().nullish(),
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.query(async ({ input: { event, property, projectId, ...input } }) => {
|
.query(async ({ input: { event, property, projectId, ...input } }) => {
|
||||||
const { startDate, endDate } = getChartStartEndDate(input);
|
|
||||||
if (property === 'has_profile') {
|
if (property === 'has_profile') {
|
||||||
return {
|
return {
|
||||||
values: ['true', 'false'],
|
values: ['true', 'false'],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const { sb, getSql } = createSqlBuilder();
|
const values: string[] = [];
|
||||||
sb.where.project_id = `project_id = ${escape(projectId)}`;
|
|
||||||
if (event !== '*') {
|
if (property.startsWith('properties.')) {
|
||||||
sb.where.event = `name = ${escape(event)}`;
|
const propertyKey = property.replace(/^properties\./, '');
|
||||||
|
|
||||||
|
const res = await chQuery<{
|
||||||
|
property_value: string;
|
||||||
|
created_at: string;
|
||||||
|
}>(
|
||||||
|
`SELECT
|
||||||
|
distinct property_value,
|
||||||
|
max(created_at) as created_at
|
||||||
|
FROM ${TABLE_NAMES.event_property_values_mv}
|
||||||
|
WHERE project_id = ${escape(projectId)}
|
||||||
|
AND property_key = ${escape(propertyKey)}
|
||||||
|
${event && event !== '*' ? `AND name = ${escape(event)}` : ''}
|
||||||
|
GROUP BY property_value
|
||||||
|
ORDER BY created_at DESC`,
|
||||||
|
);
|
||||||
|
|
||||||
|
values.push(...res.map((e) => e.property_value));
|
||||||
|
} else {
|
||||||
|
const { sb, getSql } = createSqlBuilder();
|
||||||
|
sb.where.project_id = `project_id = ${escape(projectId)}`;
|
||||||
|
if (event !== '*') {
|
||||||
|
sb.where.event = `name = ${escape(event)}`;
|
||||||
|
}
|
||||||
|
sb.select.values = `distinct ${getSelectPropertyKey(property)} as values`;
|
||||||
|
sb.where.date = `${toDate('created_at', 'month')} > now() - INTERVAL 6 MONTH`;
|
||||||
|
const events = await chQuery<{ values: string[] }>(getSql());
|
||||||
|
|
||||||
|
values.push(
|
||||||
|
...pipe(
|
||||||
|
(data: typeof events) => map(prop('values'), data),
|
||||||
|
flatten,
|
||||||
|
uniq,
|
||||||
|
sort((a, b) => a.length - b.length),
|
||||||
|
)(events),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
sb.select.values = `distinct ${getSelectPropertyKey(property)} as values`;
|
|
||||||
sb.where.date = `${toDate('created_at', input.interval)} BETWEEN ${toDate(formatClickhouseDate(startDate), input.interval)} AND ${toDate(formatClickhouseDate(endDate), input.interval)};`;
|
|
||||||
|
|
||||||
const events = await chQuery<{ values: string[] }>(getSql());
|
|
||||||
|
|
||||||
const values = pipe(
|
|
||||||
(data: typeof events) => map(prop('values'), data),
|
|
||||||
flatten,
|
|
||||||
uniq,
|
|
||||||
sort((a, b) => a.length - b.length),
|
|
||||||
)(events);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
values,
|
values,
|
||||||
@@ -204,4 +235,208 @@ export const chartRouter = createTRPCRouter({
|
|||||||
|
|
||||||
return getChart(input);
|
return getChart(input);
|
||||||
}),
|
}),
|
||||||
|
cohort: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
projectId: z.string(),
|
||||||
|
firstEvent: z.array(z.string()).min(1),
|
||||||
|
secondEvent: z.array(z.string()).min(1),
|
||||||
|
criteria: zCriteria.default('on_or_after'),
|
||||||
|
startDate: z.string().nullish(),
|
||||||
|
endDate: z.string().nullish(),
|
||||||
|
interval: zTimeInterval.default('day'),
|
||||||
|
range: zRange,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.query(async ({ input }) => {
|
||||||
|
const { projectId, firstEvent, secondEvent } = input;
|
||||||
|
const dates = getChartStartEndDate(input);
|
||||||
|
const diffInterval = {
|
||||||
|
minute: () => differenceInDays(dates.endDate, dates.startDate),
|
||||||
|
hour: () => differenceInDays(dates.endDate, dates.startDate),
|
||||||
|
day: () => differenceInDays(dates.endDate, dates.startDate),
|
||||||
|
week: () => differenceInWeeks(dates.endDate, dates.startDate),
|
||||||
|
month: () => differenceInMonths(dates.endDate, dates.startDate),
|
||||||
|
}[input.interval]();
|
||||||
|
const sqlInterval = {
|
||||||
|
minute: 'DAY',
|
||||||
|
hour: 'DAY',
|
||||||
|
day: 'DAY',
|
||||||
|
week: 'WEEK',
|
||||||
|
month: 'MONTH',
|
||||||
|
}[input.interval];
|
||||||
|
|
||||||
|
const sqlToStartOf = {
|
||||||
|
minute: 'toDate',
|
||||||
|
hour: 'toDate',
|
||||||
|
day: 'toDate',
|
||||||
|
week: 'toStartOfWeek',
|
||||||
|
month: 'toStartOfMonth',
|
||||||
|
}[input.interval];
|
||||||
|
|
||||||
|
const countCriteria = input.criteria === 'on_or_after' ? '>=' : '=';
|
||||||
|
|
||||||
|
const usersSelect = range(0, diffInterval + 1)
|
||||||
|
.map(
|
||||||
|
(index) =>
|
||||||
|
`groupUniqArrayIf(profile_id, x_after_cohort ${countCriteria} ${index}) AS interval_${index}_users`,
|
||||||
|
)
|
||||||
|
.join(',\n');
|
||||||
|
|
||||||
|
const countsSelect = range(0, diffInterval + 1)
|
||||||
|
.map(
|
||||||
|
(index) =>
|
||||||
|
`length(interval_${index}_users) AS interval_${index}_user_count`,
|
||||||
|
)
|
||||||
|
.join(',\n');
|
||||||
|
|
||||||
|
const whereEventNameIs = (event: string[]) => {
|
||||||
|
if (event.length === 1) {
|
||||||
|
return `name = ${escape(event[0])}`;
|
||||||
|
}
|
||||||
|
return `name IN (${event.map((e) => escape(e)).join(',')})`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// const dropoffsSelect = range(1, diffInterval + 1)
|
||||||
|
// .map(
|
||||||
|
// (index) =>
|
||||||
|
// `arrayFilter(x -> NOT has(interval_${index}_users, x), interval_${index - 1}_users) AS interval_${index}_dropoffs`,
|
||||||
|
// )
|
||||||
|
// .join(',\n');
|
||||||
|
|
||||||
|
// const dropoffCountsSelect = range(1, diffInterval + 1)
|
||||||
|
// .map(
|
||||||
|
// (index) =>
|
||||||
|
// `length(interval_${index}_dropoffs) AS interval_${index}_dropoff_count`,
|
||||||
|
// )
|
||||||
|
// .join(',\n');
|
||||||
|
|
||||||
|
// SELECT
|
||||||
|
// project_id,
|
||||||
|
// profile_id AS userID,
|
||||||
|
// name,
|
||||||
|
// toDate(created_at) AS cohort_interval
|
||||||
|
// FROM events_v2
|
||||||
|
// WHERE profile_id != device_id
|
||||||
|
// AND ${whereEventNameIs(firstEvent)}
|
||||||
|
// AND project_id = ${escape(projectId)}
|
||||||
|
// AND created_at BETWEEN toDate('${utc(dates.startDate)}') AND toDate('${utc(dates.endDate)}')
|
||||||
|
// GROUP BY project_id, name, cohort_interval, userID
|
||||||
|
|
||||||
|
const cohortQuery = `
|
||||||
|
WITH
|
||||||
|
cohort_users AS (
|
||||||
|
SELECT
|
||||||
|
profile_id AS userID,
|
||||||
|
project_id,
|
||||||
|
${sqlToStartOf}(created_at) AS cohort_interval
|
||||||
|
FROM ${TABLE_NAMES.cohort_events_mv}
|
||||||
|
WHERE ${whereEventNameIs(firstEvent)}
|
||||||
|
AND project_id = ${escape(projectId)}
|
||||||
|
AND created_at BETWEEN toDate('${utc(dates.startDate)}') AND toDate('${utc(dates.endDate)}')
|
||||||
|
),
|
||||||
|
retention_matrix AS (
|
||||||
|
SELECT
|
||||||
|
c.cohort_interval,
|
||||||
|
e.profile_id,
|
||||||
|
dateDiff('${sqlInterval}', c.cohort_interval, ${sqlToStartOf}(e.created_at)) AS x_after_cohort
|
||||||
|
FROM cohort_users AS c
|
||||||
|
INNER JOIN ${TABLE_NAMES.cohort_events_mv} AS e ON c.userID = e.profile_id
|
||||||
|
WHERE (${whereEventNameIs(secondEvent)}) AND (e.project_id = ${escape(projectId)})
|
||||||
|
AND ((e.created_at >= c.cohort_interval) AND (e.created_at <= (c.cohort_interval + INTERVAL ${diffInterval} ${sqlInterval})))
|
||||||
|
),
|
||||||
|
interval_users AS (
|
||||||
|
SELECT
|
||||||
|
cohort_interval,
|
||||||
|
${usersSelect}
|
||||||
|
FROM retention_matrix
|
||||||
|
GROUP BY cohort_interval
|
||||||
|
),
|
||||||
|
cohort_sizes AS (
|
||||||
|
SELECT
|
||||||
|
cohort_interval,
|
||||||
|
COUNT(DISTINCT userID) AS total_first_event_count
|
||||||
|
FROM cohort_users
|
||||||
|
GROUP BY cohort_interval
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
cohort_interval,
|
||||||
|
cohort_sizes.total_first_event_count,
|
||||||
|
${countsSelect}
|
||||||
|
FROM interval_users
|
||||||
|
LEFT JOIN cohort_sizes AS cs ON cohort_interval = cs.cohort_interval
|
||||||
|
ORDER BY cohort_interval ASC
|
||||||
|
`;
|
||||||
|
|
||||||
|
const cohortData = await chQuery<{
|
||||||
|
cohort_interval: string;
|
||||||
|
total_first_event_count: number;
|
||||||
|
[key: string]: any;
|
||||||
|
}>(cohortQuery);
|
||||||
|
|
||||||
|
return processCohortData(cohortData, diffInterval);
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function processCohortData(
|
||||||
|
data: Array<{
|
||||||
|
cohort_interval: string;
|
||||||
|
total_first_event_count: number;
|
||||||
|
[key: string]: any;
|
||||||
|
}>,
|
||||||
|
diffInterval: number,
|
||||||
|
) {
|
||||||
|
if (data.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const processed = data.map((row) => {
|
||||||
|
const sum = row.total_first_event_count;
|
||||||
|
const values = range(0, diffInterval + 1).map(
|
||||||
|
(index) => (row[`interval_${index}_user_count`] || 0) as number,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
cohort_interval: row.cohort_interval,
|
||||||
|
sum,
|
||||||
|
values: values,
|
||||||
|
percentages: values.map((value) =>
|
||||||
|
sum > 0 ? round((value / sum) * 100, 2) : 0,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize aggregation for averages
|
||||||
|
const averageData: {
|
||||||
|
sum: number;
|
||||||
|
values: Array<number>;
|
||||||
|
percentages: Array<number>;
|
||||||
|
} = {
|
||||||
|
sum: 0,
|
||||||
|
values: range(0, diffInterval + 1).map(() => 0),
|
||||||
|
percentages: range(0, diffInterval + 1).map(() => 0),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Aggregate data for averages
|
||||||
|
processed.forEach((row) => {
|
||||||
|
averageData.sum += row.sum;
|
||||||
|
row.values.forEach((value, index) => {
|
||||||
|
averageData.values[index] += value;
|
||||||
|
averageData.percentages[index] += row.percentages[index]!;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const cohortCount = processed.length;
|
||||||
|
|
||||||
|
// Calculate average values
|
||||||
|
const averageRow = {
|
||||||
|
cohort_interval: 'Average',
|
||||||
|
sum: cohortCount > 0 ? round(averageData.sum / cohortCount, 0) : 0,
|
||||||
|
percentages: averageData.percentages.map((item) =>
|
||||||
|
round(item / cohortCount, 2),
|
||||||
|
),
|
||||||
|
values: averageData.values.map((item) => round(item / cohortCount, 0)),
|
||||||
|
};
|
||||||
|
|
||||||
|
return [averageRow, ...processed];
|
||||||
|
}
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export const reportRouter = createTRPCRouter({
|
|||||||
range: report.range === 'custom' ? '30d' : report.range,
|
range: report.range === 'custom' ? '30d' : report.range,
|
||||||
formula: report.formula,
|
formula: report.formula,
|
||||||
previous: report.previous ?? false,
|
previous: report.previous ?? false,
|
||||||
|
unit: report.unit,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
@@ -84,6 +85,7 @@ export const reportRouter = createTRPCRouter({
|
|||||||
range: report.range === 'custom' ? '30d' : report.range,
|
range: report.range === 'custom' ? '30d' : report.range,
|
||||||
formula: report.formula,
|
formula: report.formula,
|
||||||
previous: report.previous ?? false,
|
previous: report.previous ?? false,
|
||||||
|
unit: report.unit,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -75,10 +75,13 @@ export const zChartInput = z.object({
|
|||||||
offset: z.number().optional(),
|
offset: z.number().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const zCriteria = z.enum(['on_or_after', 'on']);
|
||||||
|
|
||||||
export const zReportInput = zChartInput.extend({
|
export const zReportInput = zChartInput.extend({
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
lineType: zLineType,
|
lineType: zLineType,
|
||||||
unit: z.string().optional(),
|
unit: z.string().optional(),
|
||||||
|
criteria: zCriteria.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const zInviteUser = z.object({
|
export const zInviteUser = z.object({
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
zChartEvent,
|
zChartEvent,
|
||||||
zChartInput,
|
zChartInput,
|
||||||
zChartType,
|
zChartType,
|
||||||
|
zCriteria,
|
||||||
zLineType,
|
zLineType,
|
||||||
zMetric,
|
zMetric,
|
||||||
zRange,
|
zRange,
|
||||||
@@ -41,6 +42,7 @@ export type IGetChartDataInput = {
|
|||||||
startDate: string;
|
startDate: string;
|
||||||
endDate: string;
|
endDate: string;
|
||||||
} & Omit<IChartInput, 'events' | 'name' | 'startDate' | 'endDate' | 'range'>;
|
} & Omit<IChartInput, 'events' | 'name' | 'startDate' | 'endDate' | 'range'>;
|
||||||
|
export type ICriteria = z.infer<typeof zCriteria>;
|
||||||
|
|
||||||
export type PreviousValue =
|
export type PreviousValue =
|
||||||
| {
|
| {
|
||||||
|
|||||||
10
pnpm-lock.yaml
generated
10
pnpm-lock.yaml
generated
@@ -341,8 +341,8 @@ importers:
|
|||||||
specifier: ^2.4.0
|
specifier: ^2.4.0
|
||||||
version: 2.4.0(react-dom@18.2.0)(react@18.2.0)
|
version: 2.4.0(react-dom@18.2.0)(react@18.2.0)
|
||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^0.331.0
|
specifier: ^0.451.0
|
||||||
version: 0.331.0(react@18.2.0)
|
version: 0.451.0(react@18.2.0)
|
||||||
mathjs:
|
mathjs:
|
||||||
specifier: ^12.3.2
|
specifier: ^12.3.2
|
||||||
version: 12.3.2
|
version: 12.3.2
|
||||||
@@ -14549,10 +14549,10 @@ packages:
|
|||||||
react: 18.2.0
|
react: 18.2.0
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
/lucide-react@0.331.0(react@18.2.0):
|
/lucide-react@0.451.0(react@18.2.0):
|
||||||
resolution: {integrity: sha512-CHFJ0ve9vaZ7bB2VRAl27SlX1ELh6pfNC0jS96qGpPEEzLkLDGq4pDBFU8RhOoRMqsjXqTzLm9U6bZ1OcIHq7Q==}
|
resolution: {integrity: sha512-OwQ3uljZLp2cerj8sboy5rnhtGTCl9UCJIhT1J85/yOuGVlEH+xaUPR7tvNdddPvmV5M5VLdr7cQuWE3hzA4jw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^16.5.1 || ^17.0.0 || ^18.0.0
|
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 18.2.0
|
react: 18.2.0
|
||||||
dev: false
|
dev: false
|
||||||
|
|||||||
@@ -16,7 +16,8 @@
|
|||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"jsx": "preserve",
|
"jsx": "preserve",
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
"noUncheckedIndexedAccess": true
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"allowUnreachableCode": true
|
||||||
},
|
},
|
||||||
"exclude": ["node_modules", "build", "dist"]
|
"exclude": ["node_modules", "build", "dist"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user