Skip to content

Commit 47a1460

Browse files
HyteqHyteq
authored andcommitted
feat: add funnels filters, update UI/UX
1 parent 85b0b9c commit 47a1460

12 files changed

Lines changed: 919 additions & 614 deletions

File tree

apps/api/src/routes/v1/funnels.ts

Lines changed: 137 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ funnelRouter.get('/', async (c) => {
5555
name: funnelDefinitions.name,
5656
description: funnelDefinitions.description,
5757
steps: funnelDefinitions.steps,
58+
filters: funnelDefinitions.filters,
5859
isActive: funnelDefinitions.isActive,
5960
createdAt: funnelDefinitions.createdAt,
6061
updatedAt: funnelDefinitions.updatedAt,
@@ -147,7 +148,7 @@ funnelRouter.post(
147148
try {
148149
const website = c.get('website')
149150
const user = c.get('user')
150-
const { name, description, steps } = await c.req.json()
151+
const { name, description, steps, filters } = await c.req.json()
151152

152153
const funnelId = crypto.randomUUID()
153154

@@ -159,6 +160,7 @@ funnelRouter.post(
159160
name,
160161
description,
161162
steps,
163+
filters,
162164
createdBy: user.id,
163165
})
164166
.returning()
@@ -425,87 +427,77 @@ funnelRouter.get('/:id/analytics', async (c) => {
425427

426428
const funnelData = funnel[0]
427429
const steps = funnelData.steps as Array<{ type: string; target: string; name: string; conditions?: any }>
430+
const filters = funnelData.filters as Array<{ field: string; operator: string; value: string | string[] }> || []
428431

429-
// Execute funnel analysis query
430-
const analysisQuery = `
431-
WITH ${steps.map((step, index: number) => {
432-
let whereCondition = '';
432+
// Build filter conditions
433+
const buildFilterConditions = () => {
434+
if (!filters || filters.length === 0) return '';
435+
436+
const filterConditions = filters.map(filter => {
437+
const field = filter.field.replace(/'/g, "''");
438+
const value = Array.isArray(filter.value) ? filter.value : [filter.value];
433439

434-
if (step.type === 'PAGE_VIEW') {
435-
// Handle page views - only screen_view events with matching path
436-
const targetPath = step.target.replace(/'/g, "''");
437-
whereCondition = `event_name = 'screen_view' AND (path = '${targetPath}' OR path LIKE '%${targetPath}')`;
438-
} else if (step.type === 'EVENT') {
439-
// Handle custom events - exclude system events like in query.ts
440-
const eventName = step.target.replace(/'/g, "''");
441-
whereCondition = `event_name = '${eventName}' AND event_name NOT IN ('screen_view', 'page_exit', 'error', 'web_vitals')`;
442-
} else if (step.type === 'CUSTOM' && step.conditions) {
443-
// Handle custom conditions with properties
444-
const eventName = step.target.replace(/'/g, "''");
445-
let customConditions = `event_name = '${eventName}' AND event_name NOT IN ('screen_view', 'page_exit', 'error', 'web_vitals')`;
446-
447-
// Add property-based conditions if specified
448-
if (step.conditions && typeof step.conditions === 'object') {
449-
Object.entries(step.conditions).forEach(([key, value]) => {
450-
if (typeof value === 'string') {
451-
customConditions += ` AND JSONExtractString(properties, '${key.replace(/'/g, "''")}') = '${String(value).replace(/'/g, "''")}'`;
452-
} else if (typeof value === 'number') {
453-
customConditions += ` AND JSONExtractFloat(properties, '${key.replace(/'/g, "''")}') = ${value}`;
454-
} else if (typeof value === 'boolean') {
455-
customConditions += ` AND JSONExtractBool(properties, '${key.replace(/'/g, "''")}') = ${value ? 1 : 0}`;
456-
}
457-
});
458-
}
459-
460-
whereCondition = customConditions;
440+
switch (filter.operator) {
441+
case 'equals':
442+
return `${field} = '${value[0].replace(/'/g, "''")}'`;
443+
case 'contains':
444+
return `${field} LIKE '%${value[0].replace(/'/g, "''")}%'`;
445+
case 'not_equals':
446+
return `${field} != '${value[0].replace(/'/g, "''")}'`;
447+
case 'in':
448+
return `${field} IN (${value.map(v => `'${v.replace(/'/g, "''")}'`).join(', ')})`;
449+
case 'not_in':
450+
return `${field} NOT IN (${value.map(v => `'${v.replace(/'/g, "''")}'`).join(', ')})`;
451+
default:
452+
return '';
461453
}
462-
463-
return `step_${index + 1}_users AS (
464-
SELECT DISTINCT
465-
session_id,
466-
anonymous_id,
467-
MIN(time) as step_time
468-
FROM analytics.events
469-
WHERE client_id = '${website.id}'
470-
AND toDate(time) >= '${startDate}'
471-
AND toDate(time) <= '${endDate}'
472-
AND ${whereCondition}
473-
GROUP BY session_id, anonymous_id
474-
)`;
475-
}).join(',\n ')},
476-
step_progression AS (
477-
${steps.map((step, index: number) => {
478-
const stepNum = index + 1;
479-
const joins = Array.from({length: stepNum}, (_, i) => {
480-
if (i === 0) return 'step_1_users s1';
481-
return `LEFT JOIN step_${i + 1}_users s${i + 1} ON s1.session_id = s${i + 1}.session_id AND s${i + 1}.step_time >= s${i}.step_time`;
482-
}).join('\n ');
483-
484-
return `
485-
SELECT
486-
${stepNum} as step_number,
487-
'${step.name.replace(/'/g, "''")}' as step_name,
488-
COUNT(DISTINCT s1.session_id) as total_users,
489-
${index === 0 ?
490-
'COUNT(DISTINCT s1.session_id)' :
491-
`COUNT(DISTINCT s${stepNum}.session_id)`} as users,
492-
${index === 0 ?
493-
'100.0' :
494-
`ROUND((COUNT(DISTINCT s${stepNum}.session_id) * 100.0 / NULLIF(COUNT(DISTINCT s1.session_id), 0)), 2)`} as conversion_rate,
495-
${index === 0 ?
496-
'0' :
497-
`(COUNT(DISTINCT s1.session_id) - COUNT(DISTINCT s${stepNum}.session_id))`} as dropoffs,
498-
${index === 0 ?
499-
'0.0' :
500-
`ROUND(((COUNT(DISTINCT s1.session_id) - COUNT(DISTINCT s${stepNum}.session_id)) * 100.0 / NULLIF(COUNT(DISTINCT s1.session_id), 0)), 2)`} as dropoff_rate,
501-
${index === 0 ?
502-
'0.0' :
503-
`ROUND(AVG(CASE WHEN s${stepNum}.step_time > s${stepNum - 1}.step_time AND dateDiff('second', s${stepNum - 1}.step_time, s${stepNum}.step_time) > 0 AND dateDiff('second', s${stepNum - 1}.step_time, s${stepNum}.step_time) < 86400 THEN dateDiff('second', s${stepNum - 1}.step_time, s${stepNum}.step_time) ELSE NULL END), 2)`} as avg_time_to_complete
504-
FROM ${joins}`;
505-
}).join('\nUNION ALL\n')}
454+
}).filter(Boolean);
455+
456+
return filterConditions.length > 0 ? ` AND ${filterConditions.join(' AND ')}` : '';
457+
};
458+
459+
const filterConditions = buildFilterConditions();
460+
461+
const stepQueries = steps.map((step, index) => {
462+
let whereCondition = '';
463+
464+
if (step.type === 'PAGE_VIEW') {
465+
const targetPath = step.target.replace(/'/g, "''");
466+
whereCondition = `event_name = 'screen_view' AND (path = '${targetPath}' OR path LIKE '%${targetPath}%')`;
467+
} else if (step.type === 'EVENT') {
468+
const eventName = step.target.replace(/'/g, "''");
469+
whereCondition = `event_name = '${eventName}'`;
470+
}
471+
472+
return `
473+
SELECT
474+
${index + 1} as step_number,
475+
'${step.name.replace(/'/g, "''")}' as step_name,
476+
session_id,
477+
MIN(time) as first_occurrence
478+
FROM analytics.events
479+
WHERE client_id = '${website.id}'
480+
AND time >= parseDateTimeBestEffort('${startDate}')
481+
AND time <= parseDateTimeBestEffort('${endDate} 23:59:59')
482+
AND ${whereCondition}${filterConditions}
483+
GROUP BY session_id`;
484+
});
485+
486+
// Get all step events and then process the funnel logic in JavaScript
487+
const analysisQuery = `
488+
WITH all_step_events AS (
489+
${stepQueries.join('\n UNION ALL\n')}
506490
)
507-
SELECT * FROM step_progression ORDER BY step_number
491+
SELECT
492+
step_number,
493+
step_name,
494+
session_id,
495+
first_occurrence
496+
FROM all_step_events
497+
ORDER BY session_id, first_occurrence
508498
`;
499+
500+
509501

510502
// Log the generated query for debugging
511503
logger.info('Generated funnel analysis query', {
@@ -514,17 +506,13 @@ funnelRouter.get('/:id/analytics', async (c) => {
514506
query: analysisQuery
515507
});
516508

517-
let analyticsResults;
509+
let rawResults;
518510
try {
519-
analyticsResults = await chQuery<{
511+
rawResults = await chQuery<{
520512
step_number: number;
521513
step_name: string;
522-
total_users: number;
523-
users: number;
524-
conversion_rate: number;
525-
dropoffs: number;
526-
dropoff_rate: number;
527-
avg_time_to_complete?: number;
514+
session_id: string;
515+
first_occurrence: number;
528516
}>(analysisQuery);
529517
} catch (sqlError: any) {
530518
logger.error('SQL query failed for funnel analytics', {
@@ -536,6 +524,70 @@ funnelRouter.get('/:id/analytics', async (c) => {
536524
throw new Error(`SQL query failed: ${sqlError.message}`);
537525
}
538526

527+
// Process the results to calculate proper funnel progression
528+
// Group events by session and calculate funnel progression
529+
const sessionEvents = new Map<string, Array<{step_number: number, step_name: string, first_occurrence: number}>>();
530+
531+
for (const event of rawResults) {
532+
if (!sessionEvents.has(event.session_id)) {
533+
sessionEvents.set(event.session_id, []);
534+
}
535+
sessionEvents.get(event.session_id)!.push({
536+
step_number: event.step_number,
537+
step_name: event.step_name,
538+
first_occurrence: event.first_occurrence
539+
});
540+
}
541+
542+
// Calculate funnel progression for each session
543+
const stepCounts = new Map<number, Set<string>>();
544+
545+
for (const [sessionId, events] of sessionEvents) {
546+
// Sort events by time
547+
events.sort((a, b) => a.first_occurrence - b.first_occurrence);
548+
549+
// Track which steps this session completed in order
550+
let currentStep = 1;
551+
const completedSteps = new Set<number>();
552+
553+
for (const event of events) {
554+
if (event.step_number === currentStep) {
555+
completedSteps.add(event.step_number);
556+
if (!stepCounts.has(event.step_number)) {
557+
stepCounts.set(event.step_number, new Set());
558+
}
559+
stepCounts.get(event.step_number)!.add(sessionId);
560+
currentStep++;
561+
}
562+
}
563+
}
564+
565+
// Build analytics results
566+
const analyticsResults = steps.map((step, index) => {
567+
const stepNumber = index + 1;
568+
const users = stepCounts.get(stepNumber)?.size || 0;
569+
const prevStepUsers = index > 0 ? (stepCounts.get(index)?.size || 0) : users;
570+
const totalUsers = stepCounts.get(1)?.size || 0;
571+
572+
const conversion_rate = index === 0 ? 100.0 :
573+
prevStepUsers > 0 ? Math.round((users / prevStepUsers) * 100 * 100) / 100 : 0;
574+
575+
const dropoffs = index > 0 ? prevStepUsers - users : 0;
576+
const dropoff_rate = index > 0 && prevStepUsers > 0 ?
577+
Math.round((dropoffs / prevStepUsers) * 100 * 100) / 100 : 0;
578+
579+
return {
580+
step_number: stepNumber,
581+
step_name: step.name,
582+
users,
583+
total_users: totalUsers,
584+
conversion_rate,
585+
dropoffs,
586+
dropoff_rate,
587+
avg_time_to_complete: 0
588+
};
589+
});
590+
539591
// Calculate overall metrics
540592
const firstStep = analyticsResults[0];
541593
const lastStep = analyticsResults[analyticsResults.length - 1];

0 commit comments

Comments
 (0)