feature(dashboard): add conversion rate graph
This commit is contained in:
@@ -84,6 +84,7 @@ export const chartTypes = {
|
||||
map: 'Map',
|
||||
funnel: 'Funnel',
|
||||
retention: 'Retention',
|
||||
conversion: 'Conversion',
|
||||
} as const;
|
||||
|
||||
export const lineTypes = {
|
||||
|
||||
@@ -13,6 +13,7 @@ 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/conversion.service';
|
||||
export * from './src/services/user.service';
|
||||
export * from './src/services/reference.service';
|
||||
export * from './src/services/id.service';
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "clients" DROP CONSTRAINT "clients_organizationId_fkey";
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "clients" ADD CONSTRAINT "clients_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "ChartType" ADD VALUE 'conversion';
|
||||
@@ -230,7 +230,7 @@ model Client {
|
||||
type ClientType @default(write)
|
||||
projectId String?
|
||||
project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||
organization Organization @relation(fields: [organizationId], references: [id])
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
organizationId String
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
@@ -257,6 +257,7 @@ enum ChartType {
|
||||
map
|
||||
funnel
|
||||
retention
|
||||
conversion
|
||||
}
|
||||
|
||||
model Dashboard {
|
||||
|
||||
199
packages/db/src/services/conversion.service.ts
Normal file
199
packages/db/src/services/conversion.service.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import { NOT_SET_VALUE } from '@openpanel/constants';
|
||||
import type { IChartInput } from '@openpanel/validation';
|
||||
import { omit } from 'ramda';
|
||||
import { TABLE_NAMES, ch } from '../clickhouse/client';
|
||||
import { clix } from '../clickhouse/query-builder';
|
||||
import {
|
||||
getEventFiltersWhereClause,
|
||||
getSelectPropertyKey,
|
||||
} from './chart.service';
|
||||
|
||||
export class ConversionService {
|
||||
constructor(private client: typeof ch) {}
|
||||
|
||||
async getConversion({
|
||||
projectId,
|
||||
startDate,
|
||||
endDate,
|
||||
funnelGroup,
|
||||
funnelWindow = 24,
|
||||
events,
|
||||
breakdowns = [],
|
||||
interval,
|
||||
}: Omit<IChartInput, 'range' | 'previous' | 'metric' | 'chartType'>) {
|
||||
const group = funnelGroup === 'profile_id' ? 'profile_id' : 'session_id';
|
||||
const breakdownColumns = breakdowns.map(
|
||||
(b, index) => `${getSelectPropertyKey(b.name)} as b_${index}`,
|
||||
);
|
||||
const breakdownGroupBy = breakdowns.map((b, index) => `b_${index}`);
|
||||
|
||||
if (events.length !== 2) {
|
||||
throw new Error('events must be an array of two events');
|
||||
}
|
||||
|
||||
if (!startDate || !endDate) {
|
||||
throw new Error('startDate and endDate are required');
|
||||
}
|
||||
|
||||
const eventA = events[0]!;
|
||||
const eventB = events[1]!;
|
||||
const whereA = Object.values(
|
||||
getEventFiltersWhereClause(eventA.filters),
|
||||
).join(' AND ');
|
||||
const whereB = Object.values(
|
||||
getEventFiltersWhereClause(eventB.filters),
|
||||
).join(' AND ');
|
||||
|
||||
const eventACte = clix(this.client)
|
||||
.select([
|
||||
`DISTINCT ${group}`,
|
||||
'created_at AS a_time',
|
||||
`${clix.toStartOf('created_at', interval)} AS event_day`,
|
||||
...breakdownColumns,
|
||||
])
|
||||
.from(TABLE_NAMES.events)
|
||||
.where('project_id', '=', projectId)
|
||||
.where('name', '=', eventA.name)
|
||||
.rawWhere(whereA)
|
||||
.where('created_at', 'BETWEEN', [
|
||||
clix.datetime(startDate),
|
||||
clix.datetime(endDate),
|
||||
]);
|
||||
|
||||
const eventBCte = clix(this.client)
|
||||
.select([group, 'created_at AS b_time'])
|
||||
.from(TABLE_NAMES.events)
|
||||
.where('project_id', '=', projectId)
|
||||
.where('name', '=', eventB.name)
|
||||
.rawWhere(whereB)
|
||||
.where('created_at', 'BETWEEN', [
|
||||
clix.datetime(startDate),
|
||||
clix.datetime(endDate),
|
||||
]);
|
||||
|
||||
const query = clix(this.client)
|
||||
.with('event_a', eventACte)
|
||||
.with('event_b', eventBCte)
|
||||
.select<{
|
||||
event_day: string;
|
||||
total_first: number;
|
||||
conversions: number;
|
||||
conversion_rate_percentage: number;
|
||||
[key: string]: string | number; // For breakdown columns
|
||||
}>([
|
||||
'event_day',
|
||||
...breakdownGroupBy,
|
||||
'count(*) AS total_first',
|
||||
'sum(if(conversion_time IS NOT NULL, 1, 0)) AS conversions',
|
||||
'round(100.0 * sum(if(conversion_time IS NOT NULL, 1, 0)) / count(*), 2) AS conversion_rate_percentage',
|
||||
])
|
||||
.from(
|
||||
clix.exp(`
|
||||
(SELECT
|
||||
a.${group},
|
||||
a.a_time,
|
||||
a.event_day,
|
||||
${breakdownGroupBy.length ? `${breakdownGroupBy.join(', ')},` : ''}
|
||||
nullIf(min(b.b_time), '1970-01-01 00:00:00.000') AS conversion_time
|
||||
FROM event_a AS a
|
||||
LEFT JOIN event_b AS b ON a.${group} = b.${group}
|
||||
AND b.b_time BETWEEN a.a_time AND a.a_time + INTERVAL ${funnelWindow} HOUR
|
||||
GROUP BY a.${group}, a.a_time, a.event_day${breakdownGroupBy.length ? `, ${breakdownGroupBy.join(', ')}` : ''})
|
||||
`),
|
||||
)
|
||||
.groupBy(['event_day', ...breakdownGroupBy]);
|
||||
|
||||
for (const order of ['event_day', ...breakdownGroupBy]) {
|
||||
query.orderBy(order);
|
||||
}
|
||||
|
||||
const results = await query.execute();
|
||||
return this.toSeries(results, breakdowns).map((serie, serieIndex) => {
|
||||
return {
|
||||
...serie,
|
||||
data: serie.data.map((d, index) => ({
|
||||
...d,
|
||||
timestamp: new Date(d.date).getTime(),
|
||||
serieIndex,
|
||||
index,
|
||||
serie: omit(['data'], serie),
|
||||
})),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private toSeries(
|
||||
data: {
|
||||
event_day: string;
|
||||
total_first: number;
|
||||
conversions: number;
|
||||
conversion_rate_percentage: number;
|
||||
[key: string]: string | number;
|
||||
}[],
|
||||
breakdowns: { name: string }[] = [],
|
||||
) {
|
||||
if (!breakdowns.length) {
|
||||
return [
|
||||
{
|
||||
id: 'conversion',
|
||||
breakdowns: [],
|
||||
data: data.map((d) => ({
|
||||
date: d.event_day,
|
||||
total: d.total_first,
|
||||
conversions: d.conversions,
|
||||
rate: d.conversion_rate_percentage,
|
||||
})),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// Group by breakdown values
|
||||
const series = data.reduce(
|
||||
(acc, d) => {
|
||||
const key =
|
||||
breakdowns.map((b, index) => d[`b_${index}`]).join('|') ||
|
||||
NOT_SET_VALUE;
|
||||
if (!acc[key]) {
|
||||
acc[key] = {
|
||||
id: key,
|
||||
breakdowns: breakdowns.map(
|
||||
(b, index) => (d[`b_${index}`] || NOT_SET_VALUE) as string,
|
||||
),
|
||||
data: [],
|
||||
};
|
||||
}
|
||||
acc[key]!.data.push({
|
||||
date: d.event_day,
|
||||
total: d.total_first,
|
||||
conversions: d.conversions,
|
||||
rate: d.conversion_rate_percentage,
|
||||
});
|
||||
return acc;
|
||||
},
|
||||
{} as Record<
|
||||
string,
|
||||
{
|
||||
id: string;
|
||||
breakdowns: string[];
|
||||
data: {
|
||||
date: string;
|
||||
total: number;
|
||||
conversions: number;
|
||||
rate: number;
|
||||
}[];
|
||||
}
|
||||
>,
|
||||
);
|
||||
|
||||
return Object.values(series).map((serie, serieIndex) => ({
|
||||
...serie,
|
||||
data: serie.data.map((item, dataIndex) => ({
|
||||
...item,
|
||||
dataIndex,
|
||||
serieIndex,
|
||||
})),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
export const conversionService = new ConversionService(ch);
|
||||
38
packages/db/test.ts
Normal file
38
packages/db/test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { conversionService } from './src/services/conversion.service';
|
||||
// 68/37
|
||||
async function main() {
|
||||
const conversion = await conversionService.getConversion({
|
||||
projectId: 'kiddokitchen-app',
|
||||
startDate: '2025-02-01',
|
||||
endDate: '2025-03-01',
|
||||
funnelGroup: 'session_id',
|
||||
breakdowns: [
|
||||
{
|
||||
name: 'os',
|
||||
},
|
||||
],
|
||||
interval: 'day',
|
||||
events: [
|
||||
{
|
||||
segment: 'event',
|
||||
name: 'screen_view',
|
||||
filters: [
|
||||
{
|
||||
name: 'path',
|
||||
operator: 'is',
|
||||
value: ['Start'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
segment: 'event',
|
||||
name: 'sign_up',
|
||||
filters: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
console.dir(conversion, { depth: null });
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -5,6 +5,7 @@ import { z } from 'zod';
|
||||
import {
|
||||
TABLE_NAMES,
|
||||
chQuery,
|
||||
conversionService,
|
||||
createSqlBuilder,
|
||||
db,
|
||||
funnelService,
|
||||
@@ -32,7 +33,6 @@ import {
|
||||
getChart,
|
||||
getChartPrevStartEndDate,
|
||||
getChartStartEndDate,
|
||||
getFunnelData,
|
||||
} from './chart.helpers';
|
||||
|
||||
function utc(date: string | Date) {
|
||||
@@ -197,6 +197,29 @@ export const chartRouter = createTRPCRouter({
|
||||
};
|
||||
}),
|
||||
|
||||
conversion: protectedProcedure.input(zChartInput).query(async ({ input }) => {
|
||||
const currentPeriod = getChartStartEndDate(input);
|
||||
const previousPeriod = getChartPrevStartEndDate(currentPeriod);
|
||||
|
||||
const [current, previous] = await Promise.all([
|
||||
conversionService.getConversion({ ...input, ...currentPeriod }),
|
||||
input.previous
|
||||
? conversionService.getConversion({ ...input, ...previousPeriod })
|
||||
: Promise.resolve(null),
|
||||
]);
|
||||
|
||||
return {
|
||||
current: current.map((serie, sIndex) => ({
|
||||
...serie,
|
||||
data: serie.data.map((d, dIndex) => ({
|
||||
...d,
|
||||
previousRate: previous?.[sIndex]?.data?.[dIndex]?.rate,
|
||||
})),
|
||||
})),
|
||||
previous,
|
||||
};
|
||||
}),
|
||||
|
||||
chart: publicProcedure.input(zChartInput).query(async ({ input, ctx }) => {
|
||||
if (ctx.session.userId) {
|
||||
const access = await getProjectAccessCached({
|
||||
|
||||
Reference in New Issue
Block a user