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 ( +
+ + + + {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 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';