diff --git a/apps/dashboard/src/components/overview/overview-top-bots.tsx b/apps/dashboard/src/components/overview/overview-top-bots.tsx index c90002a3..68089e31 100644 --- a/apps/dashboard/src/components/overview/overview-top-bots.tsx +++ b/apps/dashboard/src/components/overview/overview-top-bots.tsx @@ -30,45 +30,42 @@ const OverviewTopBots = ({ projectId }: Props) => { return ( <> -
- item.id} - columns={[ - { - name: 'Path', - render(item) { - return ( - - {getPath(item.path)} + item.id} + columns={[ + { + name: 'Path', + render(item) { + return ( + + {getPath(item.path)} + + ); + }, + }, + { + name: 'Date', + render(item) { + return ( +
+ +
{item.name}
- ); - }, + +
{item.createdAt.toLocaleDateString()}
+
+
+ ); }, - { - name: 'Date', - render(item) { - return ( -
- -
{item.name}
-
- -
{item.createdAt.toLocaleDateString()}
-
-
- ); - }, - }, - ]} - /> -
+ }, + ]} + /> - + {query.isLoading ? ( - + ) : ( - + {query.isLoading ? ( - + ) : ( - + {query.isLoading ? ( - + ) : ( <> - {/**/} + {/**/} - + {query.isLoading ? ( - + ) : ( ({ data={data ?? []} keyExtractor={keyExtractor} className={'text-sm min-h-[358px] @container'} - columnClassName="px-2 group/row items-center" + columnClassName="group/row [&>*:first-child]:pl-4 [&>*:last-child]:pr-4 [&_th]:pt-3" eachRow={(item) => { return (
@@ -43,11 +43,11 @@ export const OverviewWidgetTable = ({ ...column, className: cn( index === 0 - ? 'w-full flex-1 font-medium min-w-0' - : 'text-right justify-end row w-20 font-mono', + ? 'text-left w-full font-medium min-w-0' + : 'text-right w-20 font-mono', index !== 0 && index !== columns.length - 1 && - 'hidden @[310px]:row', + 'hidden @[310px]:table-cell', column.className, ), }; diff --git a/apps/dashboard/src/components/report-chart/funnel/chart.tsx b/apps/dashboard/src/components/report-chart/funnel/chart.tsx index bd76cea7..c5c4b3fa 100644 --- a/apps/dashboard/src/components/report-chart/funnel/chart.tsx +++ b/apps/dashboard/src/components/report-chart/funnel/chart.tsx @@ -1,257 +1,390 @@ 'use client'; import { ColorSquare } from '@/components/color-square'; -import { TooltipComplete } from '@/components/tooltip-complete'; -import { Progress } from '@/components/ui/progress'; import type { RouterOutputs } from '@/trpc/client'; import { cn } from '@/utils/cn'; -import { AlertCircleIcon } from 'lucide-react'; -import { last } from 'ramda'; +import { ChevronRightIcon, InfoIcon } from 'lucide-react'; -import { getPreviousMetric, round } from '@openpanel/common'; import { alphabetIds } from '@openpanel/constants'; +import { createChartTooltip } from '@/components/charts/chart-tooltip'; +import { Tooltiper } from '@/components/ui/tooltip'; +import { WidgetTable } from '@/components/widget-table'; import { useNumber } from '@/hooks/useNumerFormatter'; -import { PreviousDiffIndicator } from '../common/previous-diff-indicator'; -import { useReportChartContext } from '../context'; -import { MetricCardNumber } from '../metric/metric-card'; - -const findMostDropoffs = ( - steps: RouterOutputs['chart']['funnel']['current']['steps'], -) => { - return steps.reduce((acc, step) => { - if (step.dropoffCount > acc.dropoffCount) { - return step; - } - return acc; - }); -}; +import { getChartColor } from '@/utils/theme'; +import { getPreviousMetric } from '@openpanel/common'; +import { + CartesianGrid, + Line, + LineChart, + ResponsiveContainer, + XAxis, + YAxis, +} from 'recharts'; +import { useXAxisProps, useYAxisProps } from '../common/axis'; +import { PreviousDiffIndicatorPure } from '../common/previous-diff-indicator'; type Props = { - data: RouterOutputs['chart']['funnel']; + data: { + current: RouterOutputs['chart']['funnel']['current'][number]; + previous: RouterOutputs['chart']['funnel']['current'][number] | null; + }; }; -export function Chart({ +const Metric = ({ + label, + value, + enhancer, + className, +}: { + label: string; + value: React.ReactNode; + enhancer?: React.ReactNode; + className?: string; +}) => ( +
+
{label}
+
+
{value}
+ {enhancer &&
{enhancer}
} +
+
+); + +export function Summary({ data }: { data: RouterOutputs['chart']['funnel'] }) { + const number = useNumber(); + const highestConversion = data.current + .slice(0) + .sort((a, b) => b.lastStep.percent - a.lastStep.percent)[0]; + const highestCount = data.current + .slice(0) + .sort((a, b) => b.lastStep.count - a.lastStep.count)[0]; + return ( +
+ {highestConversion && ( +
+ + } + /> + + {number.formatWithUnit( + highestConversion.lastStep.percent / 100, + '%', + )} + +
+ )} + {highestCount && ( +
+ } + /> + + {number.format(highestCount.lastStep.count)} + +
+ )} +
+ ); +} + +function ChartName({ + breakdowns, + className, +}: { breakdowns: string[]; className?: string }) { + return ( +
+ {breakdowns.map((name, index) => { + return ( + <> + {index !== 0 && } + {name} + + ); + })} +
+ ); +} + +export function Tables({ data: { - current: { steps, totalSessions }, + current: { steps, mostDropoffsStep, lastStep, breakdowns }, previous, }, }: Props) { const number = useNumber(); - const { isEditMode } = useReportChartContext(); - const mostDropoffs = findMostDropoffs(steps); - const lastStep = last(steps)!; - const prevLastStep = previous?.steps ? last(previous.steps) : null; - + const hasHeader = breakdowns.length > 0; return ( -
-
-
- + {hasHeader && } +
+
+ + previous && ( + + ) } /> - - } - /> - + previous && ( + + ) } /> + {!!mostDropoffsStep && ( + + + {mostDropoffsStep?.dropoffCount} + {' '} + dropped after this event. Improve this step and your + conversion rate will likely increase. + + } + > + + + } + /> + )}
- {steps.map((step, index) => { - const percent = (step.count / totalSessions) * 100; - const isMostDropoffs = mostDropoffs.event.id === step.event.id; - return ( -
-
- - {alphabetIds[index]} - -
- {step.event.displayName} -
-
- - - Last period:{' '} - - {number.format( - previous?.steps?.[index]?.previousCount, - )} - - - -
- } - > -
- - Total: - -
- - {number.format(step.previousCount)} - -
-
- - - - Last period:{' '} - - {number.format( - previous?.steps?.[index]?.dropoffCount, - )} - - - -
- } - > -
- - Dropoff: - -
- - {isMostDropoffs && } - {number.format(step.dropoffCount)} - -
-
- - - - Last period:{' '} - - {number.format(previous?.steps?.[index]?.count)} - - - -
- } - > -
- - Current: - -
- - {number.format(step.count)} - -
-
- - - - Last period:{' '} - - {number.format(previous?.steps?.[index]?.count)} - - - -
- } - > -
- - Percent: - -
- - {Number.isNaN(percent) ? 0 : round(percent, 2)}% - -
-
- -
+ item.event.id!} + className={'text-sm @container'} + columnClassName="px-2 group/row items-center" + eachRow={(item, index) => { + return ( +
+
- -
- ); - })} + ); + }} + columns={[ + { + name: 'Event', + render: (item, index) => ( +
+ {alphabetIds[index]} + {item.event.displayName} +
+ ), + className: 'text-left font-mono font-semibold', + }, + { + name: 'Completed', + render: (item) => number.format(item.count), + className: 'text-right font-mono', + }, + { + name: 'Dropped after', + render: (item) => + item.dropoffCount !== null && item.dropoffPercent !== null + ? number.format(item.dropoffCount) + : null, + className: 'text-right font-mono', + }, + { + name: 'Conversion', + render: (item) => number.formatWithUnit(item.percent / 100, '%'), + className: 'text-right font-mono font-semibold', + }, + ]} + />
); } + +type RechartData = { + name: string; + [key: `step:percent:${number}`]: number | null; + [key: `step:data:${number}`]: + | (RouterOutputs['chart']['funnel']['current'][number] & { + step: RouterOutputs['chart']['funnel']['current'][number]['steps'][number]; + }) + | null; + [key: `prev_step:percent:${number}`]: number | null; + [key: `prev_step:data:${number}`]: + | (RouterOutputs['chart']['funnel']['current'][number] & { + step: RouterOutputs['chart']['funnel']['current'][number]['steps'][number]; + }) + | null; +}; + +const useRechartData = ({ + current, + previous, +}: RouterOutputs['chart']['funnel']): RechartData[] => { + const firstFunnel = current[0]; + return ( + firstFunnel?.steps.map((step, stepIndex) => { + return { + name: step?.event.displayName ?? '', + ...current.reduce((acc, item, index) => { + const diff = previous?.[index]; + return { + ...acc, + [`step:percent:${index}`]: item.steps[stepIndex]?.percent ?? null, + [`step:data:${index}`]: + { + ...item, + step: item.steps[stepIndex], + } ?? null, + [`prev_step:percent:${index}`]: + diff?.steps[stepIndex]?.percent ?? null, + [`prev_step:data:${index}`]: diff + ? { + ...diff, + step: diff?.steps?.[stepIndex], + } + : null, + }; + }, {}), + }; + }) ?? [] + ); +}; + +export function Chart({ data }: { data: RouterOutputs['chart']['funnel'] }) { + const rechartData = useRechartData(data); + const xAxisProps = useXAxisProps(); + const yAxisProps = useYAxisProps(); + + return ( + +
+ + + + + + {data.current.map((item, index) => ( + + ))} + + + +
+
+ ); +} + +const { Tooltip, TooltipProvider } = createChartTooltip< + RechartData, + Record +>(({ data }) => { + const number = useNumber(); + const variants = Object.keys(data).filter((key) => + key.startsWith('step:data:'), + ) as `step:data:${number}`[]; + + return ( + <> +
+
{data.name}
+
+ {variants.map((key, index) => { + const variant = data[key]; + const prevVariant = data[`prev_${key}`]; + if (!variant?.step) { + return null; + } + return ( +
+
+
+
+ +
+
+
+ + {number.formatWithUnit(variant.step.percent / 100, '%')} + + + ({number.format(variant.step.count)}) + +
+ + +
+
+
+ ); + })} + + ); +}); diff --git a/apps/dashboard/src/components/report-chart/funnel/index.tsx b/apps/dashboard/src/components/report-chart/funnel/index.tsx index ae65f6a6..d64e99cf 100644 --- a/apps/dashboard/src/components/report-chart/funnel/index.tsx +++ b/apps/dashboard/src/components/report-chart/funnel/index.tsx @@ -1,6 +1,6 @@ 'use client'; -import { api } from '@/trpc/client'; +import { type RouterOutputs, api } from '@/trpc/client'; import type { IChartInput } from '@openpanel/validation'; @@ -9,7 +9,7 @@ import { ReportChartEmpty } from '../common/empty'; import { ReportChartError } from '../common/error'; import { ReportChartLoading } from '../common/loading'; import { useReportChartContext } from '../context'; -import { Chart } from './chart'; +import { Chart, Summary, Tables } from './chart'; export function ReportFunnelChart() { const { @@ -22,6 +22,7 @@ export function ReportFunnelChart() { startDate, endDate, previous, + breakdowns, }, isLazyLoading, } = useReportChartContext(); @@ -32,7 +33,7 @@ export function ReportFunnelChart() { projectId, interval: 'day', chartType: 'funnel', - breakdowns: [], + breakdowns, funnelWindow, funnelGroup, previous, @@ -41,7 +42,6 @@ export function ReportFunnelChart() { endDate, }; const res = api.chart.funnel.useQuery(input, { - keepPreviousData: true, enabled: !isLazyLoading, }); @@ -53,11 +53,25 @@ export function ReportFunnelChart() { return ; } - if (res.data.current.steps.length === 0) { + if (res.data.current.length === 0) { return ; } - return ; + return ( +
+ {res.data.current.length > 1 && } + + {res.data.current.map((item, index) => ( + + ))} +
+ ); } function Loading() { diff --git a/apps/dashboard/src/components/report/sidebar/ReportSidebar.tsx b/apps/dashboard/src/components/report/sidebar/ReportSidebar.tsx index ef1e8699..f27b69a7 100644 --- a/apps/dashboard/src/components/report/sidebar/ReportSidebar.tsx +++ b/apps/dashboard/src/components/report/sidebar/ReportSidebar.tsx @@ -10,14 +10,14 @@ import { ReportSettings } from './ReportSettings'; export function ReportSidebar() { const { chartType } = useSelector((state) => state.report); const showFormula = chartType !== 'funnel' && chartType !== 'retention'; - const showBreakdown = chartType !== 'funnel' && chartType !== 'retention'; + const showBreakdown = chartType !== 'retention'; return ( <>
- - {showFormula && } {showBreakdown && } + {showFormula && } +
diff --git a/apps/dashboard/src/components/widget-table.tsx b/apps/dashboard/src/components/widget-table.tsx index 6c3d0a98..a1152af3 100644 --- a/apps/dashboard/src/components/widget-table.tsx +++ b/apps/dashboard/src/components/widget-table.tsx @@ -2,14 +2,14 @@ import { cn } from '@/utils/cn'; export interface Props { columns: { - name: string; - render: (item: T) => React.ReactNode; + name: React.ReactNode; + render: (item: T, index: number) => React.ReactNode; className?: string; }[]; keyExtractor: (item: T) => string; data: T[]; className?: string; - eachRow?: (item: T) => React.ReactNode; + eachRow?: (item: T, index: number) => React.ReactNode; columnClassName?: string; } @@ -43,62 +43,55 @@ export function WidgetTable({ return (
-
div]:p-2', - columnClassName, - )} - style={{ - gridTemplateColumns: - columns.length > 1 - ? `1fr ${columns - .slice(1) - .map((col) => 'auto') - .join(' ')}` - : '1fr', - }} - > - {columns.map((column) => ( -
- {column.name} -
- ))} -
-
- {data.map((item) => ( -
+ + div]:p-2', + 'border-b border-border text-right last:border-0 [&_td:first-child]:text-left', + '[&>td]:p-2', columnClassName, )} - style={{ - gridTemplateColumns: - columns.length > 1 - ? `1fr ${columns - .slice(1) - .map((col) => 'auto') - .join(' ')}` - : '1fr', - }} > - {eachRow?.(item)} {columns.map((column) => ( -
- {column.render(item)} -
+ {column.name} + ))} -
- ))} -
+ + + + {data.map((item, index) => ( + td]:p-2', + columnClassName, + )} + > + {columns.map((column, columnIndex) => ( + + {columnIndex === 0 && eachRow?.(item, index)} + {column.render(item, index)} + + ))} + + ))} + +
); diff --git a/packages/db/index.ts b/packages/db/index.ts index cf16c316..deb62854 100644 --- a/packages/db/index.ts +++ b/packages/db/index.ts @@ -12,6 +12,7 @@ export * from './src/services/reports.service'; export * from './src/services/salt.service'; export * from './src/services/share.service'; export * from './src/services/session.service'; +export * from './src/services/funnel.service'; export * from './src/services/user.service'; export * from './src/services/reference.service'; export * from './src/services/id.service'; diff --git a/packages/db/src/clickhouse/query-builder.ts b/packages/db/src/clickhouse/query-builder.ts index fbf04ed9..8449a04d 100644 --- a/packages/db/src/clickhouse/query-builder.ts +++ b/packages/db/src/clickhouse/query-builder.ts @@ -154,7 +154,7 @@ export class Query { if (!Array.isArray(value) && !(value instanceof Expression)) { throw new Error(`${operator} operator requires an array value`); } - return `${column} ${operator} (${this.escapeValue(value)})`; + return `${column} ${operator} ${this.escapeValue(value)}`; default: return `${column} ${operator} ${this.escapeValue(value!)}`; } diff --git a/packages/db/src/services/funnel.service.ts b/packages/db/src/services/funnel.service.ts new file mode 100644 index 00000000..5067929b --- /dev/null +++ b/packages/db/src/services/funnel.service.ts @@ -0,0 +1,265 @@ +import type { IChartEvent, IChartInput } from '@openpanel/validation'; +import { last, reverse } from 'ramda'; +import { escape } from 'sqlstring'; +import { ch } from '../clickhouse/client'; +import { TABLE_NAMES } from '../clickhouse/client'; +import { clix } from '../clickhouse/query-builder'; +import { createSqlBuilder } from '../sql-builder'; +import { + getEventFiltersWhereClause, + getSelectPropertyKey, +} from './chart.service'; + +export class FunnelService { + constructor(private client: typeof ch) {} + + private getFunnelGroup(group?: string) { + return group === 'profile_id' + ? [`COALESCE(nullIf(s.profile_id, ''), e.profile_id)`, 'profile_id'] + : ['session_id', 'session_id']; + } + + private getFunnelConditions(events: IChartEvent[]) { + return events.map((event) => { + const { sb, getWhere } = createSqlBuilder(); + sb.where = getEventFiltersWhereClause(event.filters); + sb.where.name = `name = ${escape(event.name)}`; + return getWhere().replace('WHERE ', ''); + }); + } + + private fillFunnel( + funnel: { level: number; count: number }[], + steps: number, + ) { + const filled = Array.from({ length: steps }, (_, index) => { + const level = index + 1; + const matchingResult = funnel.find((res) => res.level === level); + return { + level, + count: matchingResult ? matchingResult.count : 0, + }; + }); + + // Accumulate counts from top to bottom of the funnel + for (let i = filled.length - 1; i >= 0; i--) { + const step = filled[i]; + const prevStep = filled[i + 1]; + // If there's a previous step, add the count to the current step + if (step && prevStep) { + step.count += prevStep.count; + } + } + return filled.reverse(); + } + + toSeries( + funnel: { level: number; count: number; [key: string]: any }[], + breakdowns: { name: string }[] = [], + ) { + if (!breakdowns.length) { + return [ + funnel.map((f) => ({ + level: f.level, + count: f.count, + id: 'none', + breakdowns: [], + })), + ]; + } + + // Group by breakdown values + const series = funnel.reduce( + (acc, f) => { + const key = breakdowns.map((b, index) => f[`b_${index}`]).join('|'); + if (!acc[key]) { + acc[key] = []; + } + acc[key]!.push({ + id: key, + breakdowns: breakdowns.map((b, index) => f[`b_${index}`]), + level: f.level, + count: f.count, + }); + return acc; + }, + {} as Record< + string, + { + id: string; + breakdowns: string[]; + level: number; + count: number; + }[] + >, + ); + + return Object.values(series); + } + + async getFunnel({ + projectId, + startDate, + endDate, + events, + funnelWindow = 24, + funnelGroup, + breakdowns = [], + }: IChartInput) { + if (!startDate || !endDate) { + throw new Error('startDate and endDate are required'); + } + + if (events.length === 0) { + throw new Error('events are required'); + } + + const funnelWindowSeconds = funnelWindow * 3600; + const group = this.getFunnelGroup(funnelGroup); + const funnels = this.getFunnelConditions(events); + + // Create the funnel CTE + const funnelCte = clix(this.client) + .select([ + `${group[0]} AS ${group[1]}`, + ...breakdowns.map( + (b, index) => `${getSelectPropertyKey(b.name)} as b_${index}`, + ), + `windowFunnel(${funnelWindowSeconds}, 'strict_increase')(toUnixTimestamp(created_at), ${funnels.join(', ')}) AS level`, + ]) + .from(TABLE_NAMES.events, false) + .where('project_id', '=', projectId) + .where('created_at', 'BETWEEN', [ + clix.datetime(startDate), + clix.datetime(endDate), + ]) + .where( + 'name', + 'IN', + events.map((e) => e.name), + ) + .groupBy([group[1], ...breakdowns.map((b, index) => `b_${index}`)]); + + // Create the sessions CTE if needed + const sessionsCte = + group[0] !== 'session_id' + ? clix(this.client) + .select(['profile_id', 'id']) + .from(TABLE_NAMES.sessions) + .where('project_id', '=', projectId) + .where('created_at', 'BETWEEN', [ + clix.datetime(startDate), + clix.datetime(endDate), + ]) + : null; + + // Base funnel query with CTEs + const funnelQuery = clix(this.client).with('funnel', funnelCte); + + if (sessionsCte) { + funnelQuery.with('sessions', sessionsCte); + } + + funnelQuery + .select<{ + level: number; + count: number; + [key: string]: any; + }>([ + 'level', + ...breakdowns.map((b, index) => `b_${index}`), + 'count() as count', + ]) + .from('funnel') + .where('level', '!=', 0) + .groupBy(['level', ...breakdowns.map((b, index) => `b_${index}`)]) + .orderBy('level', 'DESC'); + + const funnelData = await funnelQuery.execute(); + const funnelSeries = this.toSeries(funnelData, breakdowns); + + return funnelSeries + .map((data) => { + const maxLevel = events.length; + const filledFunnelRes = this.fillFunnel( + data.map((d) => ({ level: d.level, count: d.count })), + maxLevel, + ); + + const totalSessions = last(filledFunnelRes)?.count ?? 0; + const steps = reverse(filledFunnelRes) + .reduce( + (acc, item, index, list) => { + const prev = list[index - 1] ?? { count: totalSessions }; + const next = list[index + 1]; + const event = events[item.level - 1]!; + return [ + ...acc, + { + event: { + ...event, + displayName: event.displayName ?? event.name, + }, + count: item.count, + percent: (item.count / totalSessions) * 100, + dropoffCount: next ? item.count - next.count : null, + dropoffPercent: next + ? ((item.count - next.count) / item.count) * 100 + : null, + previousCount: prev.count, + nextCount: next?.count ?? null, + }, + ]; + }, + [] as { + event: IChartEvent & { displayName: string }; + count: number; + percent: number; + dropoffCount: number | null; + dropoffPercent: number | null; + previousCount: number; + nextCount: number | null; + }[], + ) + .map((step, index, list) => { + const next = list[index + 1]; + return { + ...step, + isHighestDropoff: (() => { + // Skip if current step has no dropoff + if (!step?.dropoffCount) return false; + + // Get maximum dropoff count, excluding 0s + const maxDropoff = Math.max( + ...list + .map((s) => s.dropoffCount || 0) + .filter((count) => count > 0), + ); + + // Check if this is the first step with the highest dropoff + return ( + step.dropoffCount === maxDropoff && + list.findIndex((s) => s.dropoffCount === maxDropoff) === index + ); + })(), + }; + }); + + return { + id: data[0]?.id ?? 'none', + breakdowns: data[0]?.breakdowns ?? [], + steps, + totalSessions, + lastStep: last(steps)!, + mostDropoffsStep: steps.find((step) => step.isHighestDropoff)!, + }; + }) + .sort((a, b) => { + const aTotal = a.steps.reduce((acc, step) => acc + step.count, 0); + const bTotal = b.steps.reduce((acc, step) => acc + step.count, 0); + return bTotal - aTotal; + }); + } +} + +export const funnelService = new FunnelService(ch); diff --git a/packages/trpc/src/routers/chart.ts b/packages/trpc/src/routers/chart.ts index 4f9297a1..0fc5e16d 100644 --- a/packages/trpc/src/routers/chart.ts +++ b/packages/trpc/src/routers/chart.ts @@ -7,6 +7,7 @@ import { chQuery, createSqlBuilder, db, + funnelService, getSelectPropertyKey, toDate, } from '@openpanel/db'; @@ -184,9 +185,9 @@ export const chartRouter = createTRPCRouter({ const previousPeriod = getChartPrevStartEndDate(currentPeriod); const [current, previous] = await Promise.all([ - getFunnelData({ ...input, ...currentPeriod }), + funnelService.getFunnel({ ...input, ...currentPeriod }), input.previous - ? getFunnelData({ ...input, ...previousPeriod }) + ? funnelService.getFunnel({ ...input, ...previousPeriod }) : Promise.resolve(null), ]);