diff --git a/components/messages.js b/components/messages.js index a31e2875..68e3b3d5 100644 --- a/components/messages.js +++ b/components/messages.js @@ -161,6 +161,7 @@ export const labels = defineMessages({ overview: { id: 'labels.overview', defaultMessage: 'Overview' }, totalRecords: { id: 'labels.total-records', defaultMessage: 'Total records' }, insights: { id: 'label.insights', defaultMessage: 'Insights' }, + retention: { id: 'label.retention', defaultMessage: 'Retention' }, dropoff: { id: 'label.dropoff', defaultMessage: 'Dropoff' }, }); diff --git a/components/pages/reports/ReportDetails.js b/components/pages/reports/ReportDetails.js index c41d12f6..39cd285d 100644 --- a/components/pages/reports/ReportDetails.js +++ b/components/pages/reports/ReportDetails.js @@ -1,9 +1,11 @@ import FunnelReport from './funnel/FunnelReport'; import EventDataReport from './event-data/EventDataReport'; +import RetentionReport from './retention/RetentionReport'; const reports = { funnel: FunnelReport, 'event-data': EventDataReport, + retention: RetentionReport, }; export default function ReportDetails({ reportId, reportType }) { diff --git a/components/pages/reports/ReportTemplates.js b/components/pages/reports/ReportTemplates.js index 60ae11e7..29c193a8 100644 --- a/components/pages/reports/ReportTemplates.js +++ b/components/pages/reports/ReportTemplates.js @@ -47,6 +47,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 7253c3fa..c35afe4e 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/RetentionChart.js b/components/pages/reports/retention/RetentionChart.js new file mode 100644 index 00000000..5f7361fd --- /dev/null +++ b/components/pages/reports/retention/RetentionChart.js @@ -0,0 +1,74 @@ +import { useCallback, useContext, useMemo } from 'react'; +import { Loading, StatusLight } from 'react-basics'; +import useMessages from 'hooks/useMessages'; +import useTheme from 'hooks/useTheme'; +import BarChart from 'components/metrics/BarChart'; +import { formatLongNumber } from 'lib/format'; +import styles from './RetentionChart.module.css'; +import { ReportContext } from '../Report'; + +export function RetentionChart({ className, loading }) { + const { report } = useContext(ReportContext); + const { formatMessage, labels } = useMessages(); + const { colors } = useTheme(); + + const { parameters, data } = report || {}; + + const renderXLabel = useCallback( + (label, index) => { + return parameters.urls[index]; + }, + [parameters], + ); + + const renderTooltipPopup = useCallback((setTooltipPopup, model) => { + const { opacity, labelColors, dataPoints } = model.tooltip; + + if (!dataPoints?.length || !opacity) { + setTooltipPopup(null); + return; + } + + setTooltipPopup( + <> +
+ {formatLongNumber(dataPoints[0].raw.y)} {formatMessage(labels.visitors)} +
+
+ + {formatLongNumber(dataPoints[0].raw.z)}% {formatMessage(labels.dropoff)} + +
+ , + ); + }, []); + + const datasets = useMemo(() => { + return [ + { + label: formatMessage(labels.uniqueVisitors), + data: data, + borderWidth: 1, + ...colors.chart.visitors, + }, + ]; + }, [data]); + + if (loading) { + return ; + } + + return ( + + ); +} + +export default RetentionChart; diff --git a/components/pages/reports/retention/RetentionChart.module.css b/components/pages/reports/retention/RetentionChart.module.css new file mode 100644 index 00000000..9e1690b3 --- /dev/null +++ b/components/pages/reports/retention/RetentionChart.module.css @@ -0,0 +1,3 @@ +.loading { + height: 300px; +} diff --git a/components/pages/reports/retention/RetentionParameters.js b/components/pages/reports/retention/RetentionParameters.js new file mode 100644 index 00000000..29c0eff2 --- /dev/null +++ b/components/pages/reports/retention/RetentionParameters.js @@ -0,0 +1,44 @@ +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'; + +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 ( +
+ + + + + + + + + {formatMessage(labels.runQuery)} + + + + ); +} + +export default RetentionParameters; diff --git a/components/pages/reports/retention/RetentionReport.js b/components/pages/reports/retention/RetentionReport.js new file mode 100644 index 00000000..31d085f7 --- /dev/null +++ b/components/pages/reports/retention/RetentionReport.js @@ -0,0 +1,28 @@ +import RetentionChart from './RetentionChart'; +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: { window: 60, urls: [] }, +}; + +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 00000000..aed66b74 --- /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 00000000..4ef87986 --- /dev/null +++ b/components/pages/reports/retention/RetentionTable.js @@ -0,0 +1,19 @@ +import { useContext } from 'react'; +import DataTable from 'components/metrics/DataTable'; +import { useMessages } from 'hooks'; +import { ReportContext } from '../Report'; + +export function RetentionTable() { + const { report } = useContext(ReportContext); + const { formatMessage, labels } = useMessages(); + return ( + + ); +} + +export default RetentionTable; diff --git a/pages/api/reports/retention.ts b/pages/api/reports/retention.ts new file mode 100644 index 00000000..6b8aebcc --- /dev/null +++ b/pages/api/reports/retention.ts @@ -0,0 +1,55 @@ +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; + urls: string[]; + window: number; + dateRange: { + startDate: string; + endDate: string; + }; +} + +export interface RetentionResponse { + urls: string[]; + window: number; + 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, + urls, + window, + 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), + urls, + windowMinutes: +window, + }); + + return ok(res, data); + } + + return methodNotAllowed(res); +}; diff --git a/pages/reports/retention.js b/pages/reports/retention.js new file mode 100644 index 00000000..b7f0bd0f --- /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/reports/getRetention.ts b/queries/analytics/reports/getRetention.ts new file mode 100644 index 00000000..b2c47882 --- /dev/null +++ b/queries/analytics/reports/getRetention.ts @@ -0,0 +1,209 @@ +import clickhouse from 'lib/clickhouse'; +import { CLICKHOUSE, PRISMA, runQuery } from 'lib/db'; +import prisma from 'lib/prisma'; + +export async function getRetention( + ...args: [ + websiteId: string, + criteria: { + windowMinutes: number; + startDate: Date; + endDate: Date; + urls: string[]; + }, + ] +) { + return runQuery({ + [PRISMA]: () => relationalQuery(...args), + [CLICKHOUSE]: () => clickhouseQuery(...args), + }); +} + +async function relationalQuery( + websiteId: string, + criteria: { + windowMinutes: number; + startDate: Date; + endDate: Date; + urls: string[]; + }, +): Promise< + { + x: string; + y: number; + z: number; + }[] +> { + const { windowMinutes, startDate, endDate, urls } = criteria; + const { rawQuery, getAddMinutesQuery } = prisma; + const { levelQuery, sumQuery } = getRetentionQuery(urls, windowMinutes); + + function getRetentionQuery( + urls: string[], + windowMinutes: number, + ): { + levelQuery: string; + sumQuery: string; + } { + return urls.reduce( + (pv, cv, i) => { + const levelNumber = i + 1; + const startSum = i > 0 ? 'union ' : ''; + + if (levelNumber >= 2) { + pv.levelQuery += ` + , level${levelNumber} AS ( + select distinct we.session_id, we.created_at + from level${i} l + join website_event we + on l.session_id = we.session_id + where we.created_at between l.created_at + and ${getAddMinutesQuery(`l.created_at `, windowMinutes)} + and we.referrer_path = {{${i - 1}}} + and we.url_path = {{${i}}} + and we.created_at <= {{endDate}} + and we.website_id = {{websiteId::uuid}} + )`; + } + + pv.sumQuery += `\n${startSum}select ${levelNumber} as level, count(distinct(session_id)) as count from level${levelNumber}`; + + return pv; + }, + { + levelQuery: '', + sumQuery: '', + }, + ); + } + + return rawQuery( + ` + WITH level1 AS ( + select distinct session_id, created_at + from website_event + where website_id = {{websiteId::uuid}} + and created_at between {{startDate}} and {{endDate}} + and url_path = {{0}} + ) + ${levelQuery} + ${sumQuery} + ORDER BY level; + `, + { + websiteId, + startDate, + endDate, + ...urls, + }, + ).then(results => { + return urls.map((a, i) => ({ + x: a, + y: results[i]?.count || 0, + z: (1 - Number(results[i]?.count) / Number(results[i - 1]?.count)) * 100 || 0, // drop off + })); + }); +} + +async function clickhouseQuery( + websiteId: string, + criteria: { + windowMinutes: number; + startDate: Date; + endDate: Date; + urls: string[]; + }, +): Promise< + { + x: string; + y: number; + z: number; + }[] +> { + const { windowMinutes, startDate, endDate, urls } = criteria; + const { rawQuery } = clickhouse; + const { levelQuery, sumQuery, urlFilterQuery, urlParams } = getRetentionQuery( + urls, + windowMinutes, + ); + + function getRetentionQuery( + urls: string[], + windowMinutes: number, + ): { + levelQuery: string; + sumQuery: string; + urlFilterQuery: string; + urlParams: { [key: string]: string }; + } { + return urls.reduce( + (pv, cv, i) => { + const levelNumber = i + 1; + const startSum = i > 0 ? 'union all ' : ''; + const startFilter = i > 0 ? ', ' : ''; + + if (levelNumber >= 2) { + pv.levelQuery += `\n + , level${levelNumber} AS ( + select distinct y.session_id as session_id, + y.url_path as url_path, + y.referrer_path as referrer_path, + y.created_at as created_at + from level${i} x + join level0 y + on x.session_id = y.session_id + where y.created_at between x.created_at and x.created_at + interval ${windowMinutes} minute + and y.referrer_path = {url${i - 1}:String} + and y.url_path = {url${i}:String} + )`; + } + + pv.sumQuery += `\n${startSum}select ${levelNumber} as level, count(distinct(session_id)) as count from level${levelNumber}`; + pv.urlFilterQuery += `${startFilter}{url${i}:String} `; + pv.urlParams[`url${i}`] = cv; + + return pv; + }, + { + levelQuery: '', + sumQuery: '', + urlFilterQuery: '', + urlParams: {}, + }, + ); + } + + return rawQuery<{ level: number; count: number }[]>( + ` + WITH level0 AS ( + select distinct session_id, url_path, referrer_path, created_at + from umami.website_event + where url_path in (${urlFilterQuery}) + and website_id = {websiteId:UUID} + and created_at between {startDate:DateTime64} and {endDate:DateTime64} + ), + level1 AS ( + select * + from level0 + where url_path = {url0:String} + ) + ${levelQuery} + select * + from ( + ${sumQuery} + ) ORDER BY level; + `, + { + websiteId, + startDate, + endDate, + ...urlParams, + }, + ).then(results => { + return urls.map((a, i) => ({ + x: a, + y: results[i]?.count || 0, + z: (1 - Number(results[i]?.count) / Number(results[i - 1]?.count)) * 100 || 0, // drop off + })); + }); +} diff --git a/queries/index.js b/queries/index.js index f509e039..0fb2bf2c 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';