Skip to content

Commit dd39461

Browse files
committed
feat: retention
1 parent ae15df5 commit dd39461

File tree

8 files changed

+1093
-8
lines changed

8 files changed

+1093
-8
lines changed

apps/api/src/query/builders/engagement.ts

Lines changed: 342 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Analytics } from "../../types/tables";
2-
import type { SimpleQueryConfig } from "../types";
2+
import type { Filter, SimpleQueryConfig, TimeUnit } from "../types";
33

44
export const EngagementBuilders: Record<string, SimpleQueryConfig> = {
55
scroll_depth_summary: {
@@ -80,12 +80,12 @@ export const EngagementBuilders: Record<string, SimpleQueryConfig> = {
8080
table: Analytics.events,
8181
fields: [
8282
"CASE " +
83-
'WHEN scroll_depth < 0.25 THEN "0-25%" ' +
84-
'WHEN scroll_depth < 0.5 THEN "25-50%" ' +
85-
'WHEN scroll_depth < 0.75 THEN "50-75%" ' +
86-
'WHEN scroll_depth < 1.0 THEN "75-100%" ' +
87-
'ELSE "100%" ' +
88-
"END as depth_range",
83+
'WHEN scroll_depth < 0.25 THEN "0-25%" ' +
84+
'WHEN scroll_depth < 0.5 THEN "25-50%" ' +
85+
'WHEN scroll_depth < 0.75 THEN "50-75%" ' +
86+
'WHEN scroll_depth < 1.0 THEN "75-100%" ' +
87+
'ELSE "100%" ' +
88+
"END as depth_range",
8989
"COUNT(DISTINCT anonymous_id) as visitors",
9090
"COUNT(DISTINCT session_id) as sessions",
9191
"ROUND((COUNT(DISTINCT session_id) / SUM(COUNT(DISTINCT session_id)) OVER()) * 100, 2) as percentage",
@@ -203,4 +203,339 @@ export const EngagementBuilders: Record<string, SimpleQueryConfig> = {
203203
timeField: "time",
204204
customizable: true,
205205
},
206+
207+
retention_cohorts: {
208+
meta: {
209+
title: "Retention Cohorts",
210+
description:
211+
"User retention analysis by cohort, showing what percentage of users return over time based on their first visit date.",
212+
category: "Engagement",
213+
tags: ["retention", "cohorts", "user behavior"],
214+
output_fields: [
215+
{
216+
name: "cohort",
217+
type: "string",
218+
label: "Cohort",
219+
description: "First visit date cohort",
220+
},
221+
{
222+
name: "users",
223+
type: "number",
224+
label: "Users",
225+
description: "Number of users in this cohort",
226+
},
227+
{
228+
name: "week_0_retention",
229+
type: "number",
230+
label: "Week 0 Retention",
231+
description: "Percentage of users in the initial week (always 100%)",
232+
unit: "%",
233+
},
234+
{
235+
name: "week_1_retention",
236+
type: "number",
237+
label: "Week 1 Retention",
238+
description: "Percentage of users who returned in week 1",
239+
unit: "%",
240+
},
241+
{
242+
name: "week_2_retention",
243+
type: "number",
244+
label: "Week 2 Retention",
245+
description: "Percentage of users who returned in week 2",
246+
unit: "%",
247+
},
248+
{
249+
name: "week_3_retention",
250+
type: "number",
251+
label: "Week 3 Retention",
252+
description: "Percentage of users who returned in week 3",
253+
unit: "%",
254+
},
255+
{
256+
name: "week_4_retention",
257+
type: "number",
258+
label: "Week 4 Retention",
259+
description: "Percentage of users who returned in week 4",
260+
unit: "%",
261+
},
262+
{
263+
name: "week_5_retention",
264+
type: "number",
265+
label: "Week 5 Retention",
266+
description: "Percentage of users who returned in week 5",
267+
unit: "%",
268+
},
269+
],
270+
default_visualization: "table",
271+
supports_granularity: ["day", "week", "month"],
272+
version: "1.0",
273+
},
274+
customSql: (
275+
websiteId: string,
276+
startDate: string,
277+
endDate: string,
278+
_filters?: Filter[],
279+
_granularity?: TimeUnit,
280+
_limit?: number,
281+
_offset?: number,
282+
_timezone?: string,
283+
filterConditions?: string[],
284+
filterParams?: Record<string, Filter["value"]>
285+
) => {
286+
const combinedWhereClause = filterConditions?.length
287+
? `AND ${filterConditions.join(" AND ")}`
288+
: "";
289+
290+
return {
291+
sql: `
292+
WITH first_visits AS (
293+
SELECT
294+
anonymous_id,
295+
toStartOfWeek(toDate(MIN(time))) as first_visit_week
296+
FROM analytics.events
297+
WHERE
298+
client_id = {websiteId:String}
299+
AND time >= parseDateTimeBestEffort({startDate:String})
300+
AND time <= parseDateTimeBestEffort({endDate:String})
301+
AND anonymous_id != ''
302+
AND event_name = 'screen_view'
303+
${combinedWhereClause}
304+
GROUP BY anonymous_id
305+
),
306+
cohorts AS (
307+
SELECT
308+
fv.first_visit_week as cohort_week,
309+
count(DISTINCT fv.anonymous_id) as total_users
310+
FROM first_visits fv
311+
GROUP BY fv.first_visit_week
312+
),
313+
user_visits AS (
314+
SELECT
315+
e.anonymous_id,
316+
toDate(e.time) as visit_date
317+
FROM analytics.events e
318+
WHERE
319+
e.client_id = {websiteId:String}
320+
AND e.time >= parseDateTimeBestEffort({startDate:String})
321+
AND e.time <= parseDateTimeBestEffort({endDate:String})
322+
AND e.anonymous_id != ''
323+
AND e.event_name = 'screen_view'
324+
${combinedWhereClause}
325+
GROUP BY e.anonymous_id, toDate(e.time)
326+
),
327+
retention_calc AS (
328+
SELECT
329+
fv.first_visit_week as cohort,
330+
count(DISTINCT CASE
331+
WHEN uv.visit_date >= fv.first_visit_week
332+
AND uv.visit_date < fv.first_visit_week + INTERVAL 7 DAY
333+
THEN fv.anonymous_id
334+
ELSE NULL
335+
END) as week_0_returned,
336+
count(DISTINCT CASE
337+
WHEN uv.visit_date >= fv.first_visit_week + INTERVAL 7 DAY
338+
AND uv.visit_date < fv.first_visit_week + INTERVAL 14 DAY
339+
THEN fv.anonymous_id
340+
ELSE NULL
341+
END) as week_1_returned,
342+
count(DISTINCT CASE
343+
WHEN uv.visit_date >= fv.first_visit_week + INTERVAL 14 DAY
344+
AND uv.visit_date < fv.first_visit_week + INTERVAL 21 DAY
345+
THEN fv.anonymous_id
346+
ELSE NULL
347+
END) as week_2_returned,
348+
count(DISTINCT CASE
349+
WHEN uv.visit_date >= fv.first_visit_week + INTERVAL 21 DAY
350+
AND uv.visit_date < fv.first_visit_week + INTERVAL 28 DAY
351+
THEN fv.anonymous_id
352+
ELSE NULL
353+
END) as week_3_returned,
354+
count(DISTINCT CASE
355+
WHEN uv.visit_date >= fv.first_visit_week + INTERVAL 28 DAY
356+
AND uv.visit_date < fv.first_visit_week + INTERVAL 35 DAY
357+
THEN fv.anonymous_id
358+
ELSE NULL
359+
END) as week_4_returned,
360+
count(DISTINCT CASE
361+
WHEN uv.visit_date >= fv.first_visit_week + INTERVAL 35 DAY
362+
AND uv.visit_date < fv.first_visit_week + INTERVAL 42 DAY
363+
THEN fv.anonymous_id
364+
ELSE NULL
365+
END) as week_5_returned,
366+
c.total_users
367+
FROM first_visits fv
368+
LEFT JOIN user_visits uv ON fv.anonymous_id = uv.anonymous_id
369+
INNER JOIN cohorts c ON fv.first_visit_week = c.cohort_week
370+
GROUP BY fv.first_visit_week, c.total_users
371+
)
372+
SELECT
373+
formatDateTime(cohort, '%Y-%m-%d') as cohort,
374+
total_users as users,
375+
100.0 as week_0_retention,
376+
ROUND(CASE
377+
WHEN total_users > 0
378+
THEN (week_1_returned / total_users) * 100
379+
ELSE 0
380+
END, 2) as week_1_retention,
381+
ROUND(CASE
382+
WHEN total_users > 0
383+
THEN (week_2_returned / total_users) * 100
384+
ELSE 0
385+
END, 2) as week_2_retention,
386+
ROUND(CASE
387+
WHEN total_users > 0
388+
THEN (week_3_returned / total_users) * 100
389+
ELSE 0
390+
END, 2) as week_3_retention,
391+
ROUND(CASE
392+
WHEN total_users > 0
393+
THEN (week_4_returned / total_users) * 100
394+
ELSE 0
395+
END, 2) as week_4_retention,
396+
ROUND(CASE
397+
WHEN total_users > 0
398+
THEN (week_5_returned / total_users) * 100
399+
ELSE 0
400+
END, 2) as week_5_retention
401+
FROM retention_calc
402+
GROUP BY cohort, total_users, week_1_returned, week_2_returned, week_3_returned, week_4_returned, week_5_returned
403+
ORDER BY cohort DESC
404+
`,
405+
params: {
406+
websiteId,
407+
startDate,
408+
endDate: `${endDate} 23:59:59`,
409+
...filterParams,
410+
},
411+
};
412+
},
413+
plugins: {
414+
normalizeGeo: true,
415+
},
416+
},
417+
418+
retention_rate: {
419+
meta: {
420+
title: "Retention Rate",
421+
description:
422+
"Overall user retention metrics showing return visitor rates over time.",
423+
category: "Engagement",
424+
tags: ["retention", "user behavior", "engagement"],
425+
output_fields: [
426+
{
427+
name: "date",
428+
type: "string",
429+
label: "Date",
430+
description: "Date of the retention calculation",
431+
},
432+
{
433+
name: "new_users",
434+
type: "number",
435+
label: "New Users",
436+
description: "Number of new users (first visit)",
437+
},
438+
{
439+
name: "returning_users",
440+
type: "number",
441+
label: "Returning Users",
442+
description: "Number of returning users",
443+
},
444+
{
445+
name: "retention_rate",
446+
type: "number",
447+
label: "Retention Rate",
448+
description: "Percentage of returning users",
449+
unit: "%",
450+
},
451+
],
452+
default_visualization: "line",
453+
supports_granularity: ["day", "week", "month"],
454+
version: "1.0",
455+
},
456+
customSql: (
457+
websiteId: string,
458+
startDate: string,
459+
endDate: string,
460+
_filters?: Filter[],
461+
_granularity?: TimeUnit,
462+
_limit?: number,
463+
_offset?: number,
464+
_timezone?: string,
465+
filterConditions?: string[],
466+
filterParams?: Record<string, Filter["value"]>
467+
) => {
468+
const combinedWhereClause = filterConditions?.length
469+
? `AND ${filterConditions.join(" AND ")}`
470+
: "";
471+
472+
return {
473+
sql: `
474+
WITH all_first_visits AS (
475+
SELECT
476+
anonymous_id,
477+
toDate(MIN(time)) as first_visit_date
478+
FROM analytics.events
479+
WHERE
480+
client_id = {websiteId:String}
481+
AND anonymous_id != ''
482+
AND event_name = 'screen_view'
483+
GROUP BY anonymous_id
484+
),
485+
daily_visits AS (
486+
SELECT
487+
e.anonymous_id,
488+
toDate(e.time) as visit_date
489+
FROM analytics.events e
490+
WHERE
491+
e.client_id = {websiteId:String}
492+
AND e.time >= parseDateTimeBestEffort({startDate:String})
493+
AND e.time <= parseDateTimeBestEffort({endDate:String})
494+
AND e.anonymous_id != ''
495+
AND e.event_name = 'screen_view'
496+
${combinedWhereClause}
497+
GROUP BY e.anonymous_id, toDate(e.time)
498+
),
499+
daily_stats AS (
500+
SELECT
501+
dv.visit_date as date,
502+
count(DISTINCT CASE
503+
WHEN afv.first_visit_date = dv.visit_date
504+
THEN dv.anonymous_id
505+
ELSE NULL
506+
END) as new_users,
507+
count(DISTINCT CASE
508+
WHEN afv.first_visit_date < dv.visit_date
509+
THEN dv.anonymous_id
510+
ELSE NULL
511+
END) as returning_users,
512+
count(DISTINCT dv.anonymous_id) as total_users
513+
FROM daily_visits dv
514+
LEFT JOIN all_first_visits afv ON dv.anonymous_id = afv.anonymous_id
515+
GROUP BY dv.visit_date
516+
)
517+
SELECT
518+
formatDateTime(date, '%Y-%m-%d') as date,
519+
new_users,
520+
returning_users,
521+
ROUND(CASE
522+
WHEN total_users > 0
523+
THEN (returning_users / total_users) * 100
524+
ELSE 0
525+
END, 2) as retention_rate
526+
FROM daily_stats
527+
ORDER BY date ASC
528+
`,
529+
params: {
530+
websiteId,
531+
startDate,
532+
endDate: `${endDate} 23:59:59`,
533+
...filterParams,
534+
},
535+
};
536+
},
537+
plugins: {
538+
normalizeGeo: true,
539+
},
540+
},
206541
};

0 commit comments

Comments
 (0)