feature(dashboard): add conversion rate graph

This commit is contained in:
Carl-Gerhard Lindesvärd
2025-03-28 09:21:10 +01:00
parent be358ea886
commit 8a21fadc0d
23 changed files with 807 additions and 29 deletions

View File

@@ -84,6 +84,7 @@ export const chartTypes = {
map: 'Map',
funnel: 'Funnel',
retention: 'Retention',
conversion: 'Conversion',
} as const;
export const lineTypes = {

View File

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

View File

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

View File

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

View File

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

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

View File

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