feature(dashboard): add new retention chart type

This commit is contained in:
Carl-Gerhard Lindesvärd
2024-10-15 20:40:24 +02:00
committed by Carl-Gerhard Lindesvärd
parent e2065da16e
commit f977c5454a
53 changed files with 1463 additions and 364 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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>
</>
);
}

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -30,7 +30,8 @@
"noNonNullAssertion": "off" "noNonNullAssertion": "off"
}, },
"correctness": { "correctness": {
"useExhaustiveDependencies": "off" "useExhaustiveDependencies": "off",
"noUnreachable": "off"
}, },
"performance": { "performance": {
"noDelete": "off", "noDelete": "off",

View File

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

View File

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

View File

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

View 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

View File

@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "ChartType" ADD VALUE 'retention';

View File

@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "Interval" ADD VALUE 'week';

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "reports" ADD COLUMN "criteria" TEXT;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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