diff --git a/components/messages.js b/components/messages.js
index b8605126e..85a46ff5b 100644
--- a/components/messages.js
+++ b/components/messages.js
@@ -163,6 +163,7 @@ export const labels = defineMessages({
overview: { id: 'label.overview', defaultMessage: 'Overview' },
totalRecords: { id: 'label.total-records', defaultMessage: 'Total records' },
insights: { id: 'label.insights', defaultMessage: 'Insights' },
+ retention: { id: 'label.retention', defaultMessage: 'Retention' },
dropoff: { id: 'label.dropoff', defaultMessage: 'Dropoff' },
referrer: { id: 'label.referrer', defaultMessage: 'Referrer' },
country: { id: 'label.country', defaultMessage: 'Country' },
diff --git a/components/pages/reports/ReportDetails.js b/components/pages/reports/ReportDetails.js
index df1307609..df1589af7 100644
--- a/components/pages/reports/ReportDetails.js
+++ b/components/pages/reports/ReportDetails.js
@@ -1,11 +1,13 @@
import FunnelReport from './funnel/FunnelReport';
import EventDataReport from './event-data/EventDataReport';
import InsightsReport from './insights/InsightsReport';
+import RetentionReport from './retention/RetentionReport';
const reports = {
funnel: FunnelReport,
'event-data': EventDataReport,
insights: InsightsReport,
+ retention: RetentionReport,
};
export default function ReportDetails({ reportId, reportType }) {
diff --git a/components/pages/reports/ReportTemplates.js b/components/pages/reports/ReportTemplates.js
index c1e0acdf3..1de7de9c7 100644
--- a/components/pages/reports/ReportTemplates.js
+++ b/components/pages/reports/ReportTemplates.js
@@ -45,6 +45,12 @@ export function ReportTemplates() {
url: '/reports/funnel',
icon: ,
},
+ {
+ title: formatMessage(labels.retention),
+ description: 'Track your websites user retention',
+ url: '/reports/retention',
+ icon: ,
+ },
];
return (
diff --git a/components/pages/reports/funnel/FunnelChart.js b/components/pages/reports/funnel/FunnelChart.js
index 7253c3fa5..c35afe4e6 100644
--- a/components/pages/reports/funnel/FunnelChart.js
+++ b/components/pages/reports/funnel/FunnelChart.js
@@ -1,5 +1,5 @@
import { useCallback, useContext, useMemo } from 'react';
-import { Loading } from 'react-basics';
+import { Loading, StatusLight } from 'react-basics';
import useMessages from 'hooks/useMessages';
import useTheme from 'hooks/useTheme';
import BarChart from 'components/metrics/BarChart';
@@ -22,14 +22,25 @@ export function FunnelChart({ className, loading }) {
);
const renderTooltipPopup = useCallback((setTooltipPopup, model) => {
- const { opacity, dataPoints } = model.tooltip;
+ const { opacity, labelColors, dataPoints } = model.tooltip;
if (!dataPoints?.length || !opacity) {
setTooltipPopup(null);
return;
}
- setTooltipPopup(`${formatLongNumber(dataPoints[0].raw.y)} ${formatMessage(labels.visitors)}`);
+ setTooltipPopup(
+ <>
+
+ {formatLongNumber(dataPoints[0].raw.y)} {formatMessage(labels.visitors)}
+
+
+
+ {formatLongNumber(dataPoints[0].raw.z)}% {formatMessage(labels.dropoff)}
+
+
+ >,
+ );
}, []);
const datasets = useMemo(() => {
diff --git a/components/pages/reports/retention/RetentionParameters.js b/components/pages/reports/retention/RetentionParameters.js
new file mode 100644
index 000000000..f6bde0b16
--- /dev/null
+++ b/components/pages/reports/retention/RetentionParameters.js
@@ -0,0 +1,41 @@
+import { useContext, useRef } from 'react';
+import { useMessages } from 'hooks';
+import { Form, FormButtons, FormInput, FormRow, SubmitButton, TextField } from 'react-basics';
+import { ReportContext } from 'components/pages/reports/Report';
+import BaseParameters from '../BaseParameters';
+
+const fieldOptions = [
+ { name: 'daily', type: 'string' },
+ { name: 'weekly', type: 'string' },
+];
+
+export function RetentionParameters() {
+ const { report, runReport, isRunning } = useContext(ReportContext);
+ const { formatMessage, labels } = useMessages();
+ const ref = useRef(null);
+
+ const { parameters } = report || {};
+ const { websiteId, dateRange } = parameters || {};
+ const queryDisabled = !websiteId || !dateRange;
+
+ const handleSubmit = (data, e) => {
+ e.stopPropagation();
+ e.preventDefault();
+ if (!queryDisabled) {
+ runReport(data);
+ }
+ };
+
+ return (
+
+ );
+}
+
+export default RetentionParameters;
diff --git a/components/pages/reports/retention/RetentionReport.js b/components/pages/reports/retention/RetentionReport.js
new file mode 100644
index 000000000..333496d82
--- /dev/null
+++ b/components/pages/reports/retention/RetentionReport.js
@@ -0,0 +1,26 @@
+import RetentionTable from './RetentionTable';
+import RetentionParameters from './RetentionParameters';
+import Report from '../Report';
+import ReportHeader from '../ReportHeader';
+import ReportMenu from '../ReportMenu';
+import ReportBody from '../ReportBody';
+import Funnel from 'assets/funnel.svg';
+
+const defaultParameters = {
+ type: 'retention',
+ parameters: {},
+};
+
+export default function RetentionReport({ reportId }) {
+ return (
+
+ } />
+
+
+
+
+
+
+
+ );
+}
diff --git a/components/pages/reports/retention/RetentionReport.module.css b/components/pages/reports/retention/RetentionReport.module.css
new file mode 100644
index 000000000..aed66b74e
--- /dev/null
+++ b/components/pages/reports/retention/RetentionReport.module.css
@@ -0,0 +1,10 @@
+.filters {
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ border: 1px solid var(--base400);
+ border-radius: var(--border-radius);
+ line-height: 32px;
+ padding: 10px;
+ overflow: hidden;
+}
diff --git a/components/pages/reports/retention/RetentionTable.js b/components/pages/reports/retention/RetentionTable.js
new file mode 100644
index 000000000..35d55a640
--- /dev/null
+++ b/components/pages/reports/retention/RetentionTable.js
@@ -0,0 +1,31 @@
+import { useContext } from 'react';
+import { GridTable, GridColumn } from 'react-basics';
+import { useMessages } from 'hooks';
+import { ReportContext } from '../Report';
+
+export function RetentionTable() {
+ const { report } = useContext(ReportContext);
+ const { formatMessage, labels } = useMessages();
+
+ return (
+
+
+ {row => row.date}
+
+
+ {row => row.day}
+
+
+ {row => row.visitors}
+
+
+ {row => row.returnVisitors}
+
+
+ {row => row.percentage}
+
+
+ );
+}
+
+export default RetentionTable;
diff --git a/pages/api/reports/retention.ts b/pages/api/reports/retention.ts
new file mode 100644
index 000000000..83ed0b574
--- /dev/null
+++ b/pages/api/reports/retention.ts
@@ -0,0 +1,44 @@
+import { canViewWebsite } from 'lib/auth';
+import { useCors, useAuth } from 'lib/middleware';
+import { NextApiRequestQueryBody } from 'lib/types';
+import { NextApiResponse } from 'next';
+import { ok, methodNotAllowed, unauthorized } from 'next-basics';
+import { getRetention } from 'queries';
+
+export interface RetentionRequestBody {
+ websiteId: string;
+ dateRange: { window; startDate: string; endDate: string };
+}
+
+export interface RetentionResponse {
+ startAt: number;
+ endAt: number;
+}
+
+export default async (
+ req: NextApiRequestQueryBody,
+ res: NextApiResponse,
+) => {
+ await useCors(req, res);
+ await useAuth(req, res);
+
+ if (req.method === 'POST') {
+ const {
+ websiteId,
+ dateRange: { startDate, endDate },
+ } = req.body;
+
+ if (!(await canViewWebsite(req.auth, websiteId))) {
+ return unauthorized(res);
+ }
+
+ const data = await getRetention(websiteId, {
+ startDate: new Date(startDate),
+ endDate: new Date(endDate),
+ });
+
+ return ok(res, data);
+ }
+
+ return methodNotAllowed(res);
+};
diff --git a/pages/reports/retention.js b/pages/reports/retention.js
new file mode 100644
index 000000000..b7f0bd0f8
--- /dev/null
+++ b/pages/reports/retention.js
@@ -0,0 +1,13 @@
+import AppLayout from 'components/layout/AppLayout';
+import RetentionReport from 'components/pages/reports/retention/RetentionReport';
+import useMessages from 'hooks/useMessages';
+
+export default function () {
+ const { formatMessage, labels } = useMessages();
+
+ return (
+
+
+
+ );
+}
diff --git a/queries/analytics/eventData/getEventDataFields.ts b/queries/analytics/eventData/getEventDataFields.ts
index c61de517c..f5f426e0c 100644
--- a/queries/analytics/eventData/getEventDataFields.ts
+++ b/queries/analytics/eventData/getEventDataFields.ts
@@ -21,10 +21,10 @@ async function relationalQuery(websiteId: string, filters: QueryFilters & { fiel
return rawQuery(
`
select
- event_key as fieldName,
- data_type as dataType,
- string_value as fieldValue,
- count(*) as total
+ event_key as "fieldName",
+ data_type as "dataType",
+ string_value as "fieldValue",
+ count(*) as "total"
from event_data
where website_id = {{websiteId::uuid}}
and created_at between {{startDate}} and {{endDate}}
diff --git a/queries/analytics/reports/getRetention.ts b/queries/analytics/reports/getRetention.ts
new file mode 100644
index 000000000..c34ba068e
--- /dev/null
+++ b/queries/analytics/reports/getRetention.ts
@@ -0,0 +1,166 @@
+import clickhouse from 'lib/clickhouse';
+import { CLICKHOUSE, PRISMA, runQuery } from 'lib/db';
+import prisma from 'lib/prisma';
+
+export async function getRetention(
+ ...args: [
+ websiteId: string,
+ dateRange: {
+ startDate: Date;
+ endDate: Date;
+ },
+ ]
+) {
+ return runQuery({
+ [PRISMA]: () => relationalQuery(...args),
+ [CLICKHOUSE]: () => clickhouseQuery(...args),
+ });
+}
+
+async function relationalQuery(
+ websiteId: string,
+ dateRange: {
+ startDate: Date;
+ endDate: Date;
+ },
+): Promise<
+ {
+ date: Date;
+ day: number;
+ visitors: number;
+ returnVisitors: number;
+ percentage: number;
+ }[]
+> {
+ const { startDate, endDate } = dateRange;
+ const { rawQuery } = prisma;
+
+ return rawQuery(
+ `
+ WITH cohort_items AS (
+ select session_id,
+ date_trunc('day', created_at)::date as cohort_date
+ from session
+ where website_id = {{websiteId::uuid}}
+ and created_at between {{startDate}} and {{endDate}}
+ ),
+ user_activities AS (
+ select distinct
+ w.session_id,
+ (date_trunc('day', w.created_at)::date - c.cohort_date::date) as day_number
+ from website_event w
+ join cohort_items c
+ on w.session_id = c.session_id
+ where website_id = {{websiteId::uuid}}
+ and created_at between {{startDate}} and {{endDate}}
+ ),
+ cohort_size as (
+ select cohort_date,
+ count(*) as visitors
+ from cohort_items
+ group by 1
+ order by 1
+ ),
+ cohort_date as (
+ select
+ c.cohort_date,
+ a.day_number,
+ count(*) as visitors
+ from user_activities a
+ join cohort_items c
+ on a.session_id = c.session_id
+ where a.day_number IN (0,1,2,3,4,5,6,7,14,21,30)
+ group by 1, 2
+ )
+ select
+ c.cohort_date as date,
+ c.day_number as day,
+ s.visitors,
+ c.visitors as "returnVisitors",
+ c.visitors::float * 100 / s.visitors as percentage
+ from cohort_date c
+ join cohort_size s
+ on c.cohort_date = s.cohort_date
+ order by 1, 2`,
+ {
+ websiteId,
+ startDate,
+ endDate,
+ },
+ );
+}
+
+async function clickhouseQuery(
+ websiteId: string,
+ dateRange: {
+ startDate: Date;
+ endDate: Date;
+ },
+): Promise<
+ {
+ date: Date;
+ day: number;
+ visitors: number;
+ returnVisitors: number;
+ percentage: number;
+ }[]
+> {
+ const { startDate, endDate } = dateRange;
+ const { rawQuery } = clickhouse;
+
+ return rawQuery(
+ `
+ WITH cohort_items AS (
+ select
+ min(date_trunc('day', created_at)) as cohort_date,
+ session_id
+ from website_event
+ where website_id = {websiteId:UUID}
+ and created_at between {startDate:DateTime64} and {endDate:DateTime64}
+ group by session_id
+ ),
+ user_activities AS (
+ select distinct
+ w.session_id,
+ (date_trunc('day', w.created_at) - c.cohort_date) / 86400 as day_number
+ from website_event w
+ join cohort_items c
+ on w.session_id = c.session_id
+ where website_id = {websiteId:UUID}
+ and created_at between {startDate:DateTime64} and {endDate:DateTime64}
+ ),
+ cohort_size as (
+ select cohort_date,
+ count(*) as visitors
+ from cohort_items
+ group by 1
+ order by 1
+ ),
+ cohort_date as (
+ select
+ c.cohort_date,
+ a.day_number,
+ count(*) as visitors
+ from user_activities a
+ join cohort_items c
+ on a.session_id = c.session_id
+ where a.day_number IN (0,1,2,3,4,5,6,7,14,21,30)
+ group by 1, 2
+ )
+ select
+ c.cohort_date as date,
+ c.day_number as day,
+ s.visitors as visitors,
+ c.visitors returnVisitors,
+ c.visitors * 100 / s.visitors as percentage
+ from cohort_date c
+ join cohort_size s
+ on c.cohort_date = s.cohort_date
+ order by 1, 2`,
+ {
+ websiteId,
+ startDate,
+ endDate,
+ },
+ );
+}
diff --git a/queries/index.js b/queries/index.js
index b9c0a34d9..c3255795b 100644
--- a/queries/index.js
+++ b/queries/index.js
@@ -12,6 +12,7 @@ export * from './analytics/eventData/getEventDataFields';
export * from './analytics/eventData/getEventDataUsage';
export * from './analytics/events/saveEvent';
export * from './analytics/reports/getFunnel';
+export * from './analytics/reports/getRetention';
export * from './analytics/reports/getInsights';
export * from './analytics/pageviews/getPageviewMetrics';
export * from './analytics/pageviews/getPageviewStats';