-
-
Step {index + 1}
-
- {step.event.displayName || step.event.name}
-
+
+
+ {step.event.id}
+
+
+ {step.event.displayName.replace(/_/g, ' ')}
-
-
-
-
- Sessions
-
-
-
- {step.before}
-
-
-
{step.current}
-
- {index !== 0 && (
- <>
-
- {step.current} of {totalSessions} (
- {round(step.percent, 1)}%)
-
- >
- )}
+
+
+
+ Total:
+
+ {step.previousCount}
-
- {finalStep ? (
-
-
- Conversion
-
-
+
+ Dropoff:
+
+
- {round(step.percent, 1)}%
-
-
- Converted {step.current} of {totalSessions} sessions
+ {isMostDropoffs &&
}
+ {step.dropoffCount}
+
+
+
+
+ Current:
+
+
+ {step.count}
+
- ) : (
-
-
Dropoff
-
- {round(step.dropoff.percent, 1)}%
-
-
- Lost {step.dropoff.count} sessions
-
-
- )}
+
-
+
+
);
})}
-
-
-
-
-
+
+
);
}
diff --git a/apps/dashboard/src/components/report/funnel/index.tsx b/apps/dashboard/src/components/report/funnel/index.tsx
index 0bb4a8a2..e3d4f4fe 100644
--- a/apps/dashboard/src/components/report/funnel/index.tsx
+++ b/apps/dashboard/src/components/report/funnel/index.tsx
@@ -19,35 +19,33 @@ export const Funnel = withChartProivder(function Chart({
range,
projectId,
}: ReportChartProps) {
- const [data] = api.chart.funnel.useSuspenseQuery(
- {
- events,
- name,
- range,
- projectId,
- lineType: 'monotone',
- interval: 'day',
- chartType: 'funnel',
- breakdowns: [],
- startDate: null,
- endDate: null,
- previous: false,
- formula: undefined,
- unit: undefined,
- metric: 'sum',
- },
- {
- keepPreviousData: true,
- }
- );
+ const input: IChartInput = {
+ events,
+ name,
+ range,
+ projectId,
+ lineType: 'monotone',
+ interval: 'day',
+ chartType: 'funnel',
+ breakdowns: [],
+ startDate: null,
+ endDate: null,
+ previous: false,
+ formula: undefined,
+ unit: undefined,
+ metric: 'sum',
+ };
+ const [data] = api.chart.funnel.useSuspenseQuery(input, {
+ keepPreviousData: true,
+ });
- if (data.steps.length === 0) {
+ if (data.current.steps.length === 0) {
return
;
}
return (
-
-
+
+
);
});
diff --git a/apps/dashboard/src/components/ui/progress.tsx b/apps/dashboard/src/components/ui/progress.tsx
index 77bd3f92..a8efe21a 100644
--- a/apps/dashboard/src/components/ui/progress.tsx
+++ b/apps/dashboard/src/components/ui/progress.tsx
@@ -1,28 +1,37 @@
import * as React from 'react';
import { cn } from '@/utils/cn';
+import { round } from '@/utils/math';
import * as ProgressPrimitive from '@radix-ui/react-progress';
const Progress = React.forwardRef<
React.ElementRef
,
React.ComponentPropsWithoutRef & {
color: string;
+ size?: 'sm' | 'default' | 'lg';
}
->(({ className, value, color, ...props }, ref) => (
+>(({ className, value, color, size = 'default', ...props }, ref) => (
+ {value && size != 'sm' && (
+
+ )}
));
Progress.displayName = ProgressPrimitive.Root.displayName;
diff --git a/apps/dashboard/src/components/widget-table.tsx b/apps/dashboard/src/components/widget-table.tsx
index eecaeaef..a2757101 100644
--- a/apps/dashboard/src/components/widget-table.tsx
+++ b/apps/dashboard/src/components/widget-table.tsx
@@ -18,7 +18,7 @@ export function WidgetTable({
}: Props) {
return (
-
+
{columns.map((column) => (
| {column.name} |
diff --git a/apps/dashboard/src/modals/FunnelStepDetails.tsx b/apps/dashboard/src/modals/FunnelStepDetails.tsx
new file mode 100644
index 00000000..d8978638
--- /dev/null
+++ b/apps/dashboard/src/modals/FunnelStepDetails.tsx
@@ -0,0 +1,104 @@
+'use client';
+
+import { useEffect, useRef, useState } from 'react';
+import { ListPropertiesIcon } from '@/components/events/list-properties-icon';
+import { Pagination } from '@/components/pagination';
+import { ProfileAvatar } from '@/components/profiles/profile-avatar';
+import { DialogContent } from '@/components/ui/dialog';
+import { ScrollArea } from '@/components/ui/scroll-area';
+import { Tooltiper } from '@/components/ui/tooltip';
+import { WidgetTable } from '@/components/widget-table';
+import { useAppParams } from '@/hooks/useAppParams';
+import { api } from '@/trpc/client';
+import { getProfileName } from '@/utils/getters';
+import Link from 'next/link';
+import { usePathname } from 'next/navigation';
+
+import type { IChartInput } from '@openpanel/validation';
+
+import { popModal } from '.';
+import { ModalHeader } from './Modal/Container';
+
+interface Props extends IChartInput {
+ step: number;
+}
+
+function usePrevious(value: any) {
+ const ref = useRef();
+ useEffect(() => {
+ ref.current = value;
+ });
+ return ref.current;
+}
+
+export default function FunnelStepDetails(props: Props) {
+ const [data] = api.chart.funnelStep.useSuspenseQuery(props);
+ const pathname = usePathname();
+ const prev = usePrevious(pathname);
+ const { organizationSlug, projectId } = useAppParams();
+ const [page, setPage] = useState(0);
+
+ useEffect(() => {
+ if (prev && prev !== pathname) {
+ popModal();
+ }
+ }, [pathname]);
+
+ return (
+
+
+
+ item.id}
+ columns={[
+ {
+ name: 'Name',
+ render(profile) {
+ return (
+
+
+ {getProfileName(profile)}
+
+ );
+ },
+ },
+ {
+ name: '',
+ render(profile) {
+ return ;
+ },
+ },
+ {
+ name: 'Last seen',
+ render(profile) {
+ return (
+
+
+ {profile.createdAt.toLocaleTimeString()}
+
+
+ );
+ },
+ },
+ ]}
+ />
+
+
+ );
+}
diff --git a/apps/dashboard/src/modals/index.tsx b/apps/dashboard/src/modals/index.tsx
index f80fc0bc..2442ed4b 100644
--- a/apps/dashboard/src/modals/index.tsx
+++ b/apps/dashboard/src/modals/index.tsx
@@ -56,6 +56,9 @@ const modals = {
VerifyEmail: dynamic(() => import('./VerifyEmail'), {
loading: Loading,
}),
+ FunnelStepDetails: dynamic(() => import('./FunnelStepDetails'), {
+ loading: Loading,
+ }),
};
export const { pushModal, popModal, popAllModals, ModalProvider } =
diff --git a/apps/dashboard/src/trpc/api/routers/chart.helpers.ts b/apps/dashboard/src/trpc/api/routers/chart.helpers.ts
index 867d4b0d..830ddddf 100644
--- a/apps/dashboard/src/trpc/api/routers/chart.helpers.ts
+++ b/apps/dashboard/src/trpc/api/routers/chart.helpers.ts
@@ -12,6 +12,8 @@ import {
formatClickhouseDate,
getChartSql,
getEventFiltersWhereClause,
+ getProfiles,
+ transformProfile,
} from '@openpanel/db';
import type {
IChartEvent,
@@ -183,7 +185,6 @@ export function withFormula(
const scope = {
[serie.event.id]: item?.count ?? 0,
};
-
const count = mathjs
.parse(formula)
.compile()
@@ -418,8 +419,17 @@ export function getChartPrevStartEndDate({
};
}
-export async function getFunnelData({ projectId, ...payload }: IChartInput) {
- const { startDate, endDate } = getChartStartEndDate(payload);
+const ONE_DAY_IN_SECONDS = 60 * 60 * 24;
+
+export async function getFunnelData({
+ projectId,
+ startDate,
+ endDate,
+ ...payload
+}: IChartInput) {
+ if (!startDate || !endDate) {
+ throw new Error('startDate and endDate are required');
+ }
if (payload.events.length === 0) {
return {
@@ -437,9 +447,13 @@ export async function getFunnelData({ projectId, ...payload }: IChartInput) {
const innerSql = `SELECT
session_id,
- windowFunnel(6048000000000000,'strict_increase')(toUnixTimestamp(created_at), ${funnels.join(', ')}) AS level
+ windowFunnel(${ONE_DAY_IN_SECONDS})(toUnixTimestamp(created_at), ${funnels.join(', ')}) AS level
FROM events
- WHERE (project_id = ${escape(projectId)} AND created_at >= '${formatClickhouseDate(startDate)}') AND (created_at <= '${formatClickhouseDate(endDate)}')
+ WHERE
+ project_id = ${escape(projectId)} AND
+ created_at >= '${formatClickhouseDate(startDate)}' AND
+ created_at <= '${formatClickhouseDate(endDate)}' AND
+ name IN (${payload.events.map((event) => escape(event.name)).join(', ')})
GROUP BY session_id`;
const sql = `SELECT level, count() AS count FROM (${innerSql}) GROUP BY level ORDER BY level DESC`;
@@ -491,31 +505,29 @@ export async function getFunnelData({ projectId, ...payload }: IChartInput) {
.reduce(
(acc, item, index, list) => {
const prev = list[index - 1] ?? { count: totalSessions };
+ const event = payload.events[item.level - 1]!;
return [
...acc,
{
- event: payload.events[item.level - 1]!,
- before: prev.count,
- current: item.count,
- dropoff: {
- count: prev.count - item.count,
- percent: 100 - (item.count / prev.count) * 100,
+ event: {
+ ...event,
+ displayName: event.displayName ?? event.name,
},
+ count: item.count,
percent: (item.count / totalSessions) * 100,
- prevPercent: (prev.count / totalSessions) * 100,
+ dropoffCount: prev.count - item.count,
+ dropoffPercent: 100 - (item.count / prev.count) * 100,
+ previousCount: prev.count,
},
];
},
[] as {
- event: IChartEvent;
- before: number;
- current: number;
- dropoff: {
- count: number;
- percent: number;
- };
+ event: IChartEvent & { displayName: string };
+ count: number;
percent: number;
- prevPercent: number;
+ dropoffCount: number;
+ dropoffPercent: number;
+ previousCount: number;
}[]
);
@@ -525,6 +537,63 @@ export async function getFunnelData({ projectId, ...payload }: IChartInput) {
};
}
+export async function getFunnelStep({
+ projectId,
+ startDate,
+ endDate,
+ step,
+ ...payload
+}: IChartInput & {
+ step: number;
+}) {
+ if (!startDate || !endDate) {
+ throw new Error('startDate and endDate are required');
+ }
+
+ if (payload.events.length === 0) {
+ throw new Error('no events selected');
+ }
+
+ const funnels = payload.events.map((event) => {
+ const { sb, getWhere } = createSqlBuilder();
+ sb.where = getEventFiltersWhereClause(event.filters);
+ sb.where.name = `name = ${escape(event.name)}`;
+ return getWhere().replace('WHERE ', '');
+ });
+
+ const innerSql = `SELECT
+ session_id,
+ windowFunnel(${ONE_DAY_IN_SECONDS})(toUnixTimestamp(created_at), ${funnels.join(', ')}) AS level
+ FROM events
+ WHERE
+ project_id = ${escape(projectId)} AND
+ created_at >= '${formatClickhouseDate(startDate)}' AND
+ created_at <= '${formatClickhouseDate(endDate)}' AND
+ name IN (${payload.events.map((event) => escape(event.name)).join(', ')})
+ GROUP BY session_id`;
+
+ const profileIdsQuery = `WITH sessions AS (${innerSql})
+ SELECT
+ DISTINCT e.profile_id as id
+ FROM sessions s
+ JOIN events e ON s.session_id = e.session_id
+ WHERE
+ s.level = ${step} AND
+ e.project_id = ${escape(projectId)} AND
+ e.created_at >= '${formatClickhouseDate(startDate)}' AND
+ e.created_at <= '${formatClickhouseDate(endDate)}' AND
+ name IN (${payload.events.map((event) => escape(event.name)).join(', ')})
+ ORDER BY e.created_at DESC
+ LIMIT 500
+ `;
+
+ const res = await chQuery<{
+ id: string;
+ }>(profileIdsQuery);
+
+ return getProfiles({ ids: res.map((r) => r.id) });
+}
+
export async function getSeriesFromEvents(input: IChartInput) {
const { startDate, endDate } =
input.startDate && input.endDate
diff --git a/apps/dashboard/src/trpc/api/routers/chart.ts b/apps/dashboard/src/trpc/api/routers/chart.ts
index d5756083..3e3a923f 100644
--- a/apps/dashboard/src/trpc/api/routers/chart.ts
+++ b/apps/dashboard/src/trpc/api/routers/chart.ts
@@ -16,6 +16,7 @@ import {
getChartPrevStartEndDate,
getChartStartEndDate,
getFunnelData,
+ getFunnelStep,
getSeriesFromEvents,
} from './chart.helpers';
@@ -150,9 +151,34 @@ export const chartRouter = createTRPCRouter({
}),
funnel: publicProcedure.input(zChartInput).query(async ({ input }) => {
- return getFunnelData(input);
+ const currentPeriod = getChartStartEndDate(input);
+ const previousPeriod = getChartPrevStartEndDate({
+ range: input.range,
+ ...currentPeriod,
+ });
+
+ const [current, previous] = await Promise.all([
+ getFunnelData({ ...input, ...currentPeriod }),
+ getFunnelData({ ...input, ...previousPeriod }),
+ ]);
+
+ return {
+ current,
+ previous,
+ };
}),
+ funnelStep: publicProcedure
+ .input(
+ zChartInput.extend({
+ step: z.number(),
+ })
+ )
+ .query(async ({ input }) => {
+ const currentPeriod = getChartStartEndDate(input);
+ return getFunnelStep({ ...input, ...currentPeriod });
+ }),
+
// TODO: Make this private
chart: publicProcedure.input(zChartInput).query(async ({ input }) => {
const currentPeriod = getChartStartEndDate(input);
@@ -189,7 +215,10 @@ export const chartRouter = createTRPCRouter({
return {
name: serie.name,
- event: serie.event,
+ event: {
+ ...serie.event,
+ displayName: serie.event.displayName ?? serie.event.name,
+ },
metrics: {
...metrics,
previous: {
diff --git a/apps/dashboard/tailwind.config.js b/apps/dashboard/tailwind.config.js
index 223b3199..44e39dae 100644
--- a/apps/dashboard/tailwind.config.js
+++ b/apps/dashboard/tailwind.config.js
@@ -155,7 +155,10 @@ const config = {
},
},
},
- plugins: [require('tailwindcss-animate')],
+ plugins: [
+ require('@tailwindcss/container-queries'),
+ require('tailwindcss-animate'),
+ ],
};
export default config;
diff --git a/packages/db/src/services/profile.service.ts b/packages/db/src/services/profile.service.ts
index 5cfa16f3..050eb9d6 100644
--- a/packages/db/src/services/profile.service.ts
+++ b/packages/db/src/services/profile.service.ts
@@ -122,8 +122,7 @@ export async function getProfilesByExternalId(
${getProfileSelectFields()}
FROM profiles
GROUP BY id
- HAVING project_id = ${escape(projectId)} AND external_id = ${escape(externalId)}
- `
+ HAVING project_id = ${escape(projectId)} AND external_id = ${escape(externalId)}`
);
return data.map(transformProfile);
@@ -169,7 +168,7 @@ export interface IServiceUpsertProfile {
properties?: Record;
}
-function transformProfile({
+export function transformProfile({
max_created_at,
first_name,
last_name,
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 238c6717..3efae285 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -225,6 +225,9 @@ importers:
'@t3-oss/env-nextjs':
specifier: ^0.7.3
version: 0.7.3(typescript@5.3.3)(zod@3.22.4)
+ '@tailwindcss/container-queries':
+ specifier: ^0.1.1
+ version: 0.1.1(tailwindcss@3.4.1)
'@tanstack/react-query':
specifier: ^4.36.1
version: 4.36.1(react-dom@18.2.0)(react@18.2.0)
@@ -6879,6 +6882,14 @@ packages:
zod: 3.22.4
dev: false
+ /@tailwindcss/container-queries@0.1.1(tailwindcss@3.4.1):
+ resolution: {integrity: sha512-p18dswChx6WnTSaJCSGx6lTmrGzNNvm2FtXmiO6AuA1V4U5REyoqwmT6kgAsIMdjo07QdAfYXHJ4hnMtfHzWgA==}
+ peerDependencies:
+ tailwindcss: '>=3.2.0'
+ dependencies:
+ tailwindcss: 3.4.1
+ dev: false
+
/@tailwindcss/typography@0.5.10(tailwindcss@3.4.1):
resolution: {integrity: sha512-Pe8BuPJQJd3FfRnm6H0ulKIGoMEQS+Vq01R6M5aCrFB/ccR/shT+0kXLjouGC1gFLm9hopTFN+DMP0pfwRWzPw==}
peerDependencies:
@@ -14825,22 +14836,6 @@ packages:
camelcase-css: 2.0.1
postcss: 8.4.35
- /postcss-load-config@4.0.2:
- resolution: {integrity: sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==}
- engines: {node: '>= 14'}
- peerDependencies:
- postcss: '>=8.0.9'
- ts-node: '>=9.0.0'
- peerDependenciesMeta:
- postcss:
- optional: true
- ts-node:
- optional: true
- dependencies:
- lilconfig: 3.1.0
- yaml: 2.3.4
- dev: true
-
/postcss-load-config@4.0.2(postcss@8.4.35):
resolution: {integrity: sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==}
engines: {node: '>= 14'}
@@ -17141,7 +17136,7 @@ packages:
execa: 5.1.1
globby: 11.1.0
joycon: 3.1.1
- postcss-load-config: 4.0.2
+ postcss-load-config: 4.0.2(postcss@8.4.35)
resolve-from: 5.0.0
rollup: 4.12.0
source-map: 0.8.0-beta.0