mirror of
https://github.com/umami-software/umami.git
synced 2026-02-16 10:35:35 +01:00
Merge pull request #2192 from umami-software/feat/um-376-retention-report
Feat/um 376 retention report
This commit is contained in:
commit
24669a3f70
13 changed files with 359 additions and 7 deletions
|
|
@ -163,6 +163,7 @@ export const labels = defineMessages({
|
||||||
overview: { id: 'label.overview', defaultMessage: 'Overview' },
|
overview: { id: 'label.overview', defaultMessage: 'Overview' },
|
||||||
totalRecords: { id: 'label.total-records', defaultMessage: 'Total records' },
|
totalRecords: { id: 'label.total-records', defaultMessage: 'Total records' },
|
||||||
insights: { id: 'label.insights', defaultMessage: 'Insights' },
|
insights: { id: 'label.insights', defaultMessage: 'Insights' },
|
||||||
|
retention: { id: 'label.retention', defaultMessage: 'Retention' },
|
||||||
dropoff: { id: 'label.dropoff', defaultMessage: 'Dropoff' },
|
dropoff: { id: 'label.dropoff', defaultMessage: 'Dropoff' },
|
||||||
referrer: { id: 'label.referrer', defaultMessage: 'Referrer' },
|
referrer: { id: 'label.referrer', defaultMessage: 'Referrer' },
|
||||||
country: { id: 'label.country', defaultMessage: 'Country' },
|
country: { id: 'label.country', defaultMessage: 'Country' },
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
import FunnelReport from './funnel/FunnelReport';
|
import FunnelReport from './funnel/FunnelReport';
|
||||||
import EventDataReport from './event-data/EventDataReport';
|
import EventDataReport from './event-data/EventDataReport';
|
||||||
import InsightsReport from './insights/InsightsReport';
|
import InsightsReport from './insights/InsightsReport';
|
||||||
|
import RetentionReport from './retention/RetentionReport';
|
||||||
|
|
||||||
const reports = {
|
const reports = {
|
||||||
funnel: FunnelReport,
|
funnel: FunnelReport,
|
||||||
'event-data': EventDataReport,
|
'event-data': EventDataReport,
|
||||||
insights: InsightsReport,
|
insights: InsightsReport,
|
||||||
|
retention: RetentionReport,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ReportDetails({ reportId, reportType }) {
|
export default function ReportDetails({ reportId, reportType }) {
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,12 @@ export function ReportTemplates() {
|
||||||
url: '/reports/funnel',
|
url: '/reports/funnel',
|
||||||
icon: <Funnel />,
|
icon: <Funnel />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: formatMessage(labels.retention),
|
||||||
|
description: 'Track your websites user retention',
|
||||||
|
url: '/reports/retention',
|
||||||
|
icon: <Funnel />,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useCallback, useContext, useMemo } from 'react';
|
import { useCallback, useContext, useMemo } from 'react';
|
||||||
import { Loading } from 'react-basics';
|
import { Loading, StatusLight } from 'react-basics';
|
||||||
import useMessages from 'hooks/useMessages';
|
import useMessages from 'hooks/useMessages';
|
||||||
import useTheme from 'hooks/useTheme';
|
import useTheme from 'hooks/useTheme';
|
||||||
import BarChart from 'components/metrics/BarChart';
|
import BarChart from 'components/metrics/BarChart';
|
||||||
|
|
@ -22,14 +22,25 @@ export function FunnelChart({ className, loading }) {
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderTooltipPopup = useCallback((setTooltipPopup, model) => {
|
const renderTooltipPopup = useCallback((setTooltipPopup, model) => {
|
||||||
const { opacity, dataPoints } = model.tooltip;
|
const { opacity, labelColors, dataPoints } = model.tooltip;
|
||||||
|
|
||||||
if (!dataPoints?.length || !opacity) {
|
if (!dataPoints?.length || !opacity) {
|
||||||
setTooltipPopup(null);
|
setTooltipPopup(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setTooltipPopup(`${formatLongNumber(dataPoints[0].raw.y)} ${formatMessage(labels.visitors)}`);
|
setTooltipPopup(
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
{formatLongNumber(dataPoints[0].raw.y)} {formatMessage(labels.visitors)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<StatusLight color={labelColors?.[0]?.backgroundColor}>
|
||||||
|
{formatLongNumber(dataPoints[0].raw.z)}% {formatMessage(labels.dropoff)}
|
||||||
|
</StatusLight>
|
||||||
|
</div>
|
||||||
|
</>,
|
||||||
|
);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const datasets = useMemo(() => {
|
const datasets = useMemo(() => {
|
||||||
|
|
|
||||||
41
components/pages/reports/retention/RetentionParameters.js
Normal file
41
components/pages/reports/retention/RetentionParameters.js
Normal file
|
|
@ -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 (
|
||||||
|
<Form ref={ref} values={parameters} onSubmit={handleSubmit} preventSubmit={true}>
|
||||||
|
<BaseParameters />
|
||||||
|
<FormButtons>
|
||||||
|
<SubmitButton variant="primary" disabled={queryDisabled} loading={isRunning}>
|
||||||
|
{formatMessage(labels.runQuery)}
|
||||||
|
</SubmitButton>
|
||||||
|
</FormButtons>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RetentionParameters;
|
||||||
26
components/pages/reports/retention/RetentionReport.js
Normal file
26
components/pages/reports/retention/RetentionReport.js
Normal file
|
|
@ -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 (
|
||||||
|
<Report reportId={reportId} defaultParameters={defaultParameters}>
|
||||||
|
<ReportHeader icon={<Funnel />} />
|
||||||
|
<ReportMenu>
|
||||||
|
<RetentionParameters />
|
||||||
|
</ReportMenu>
|
||||||
|
<ReportBody>
|
||||||
|
<RetentionTable />
|
||||||
|
</ReportBody>
|
||||||
|
</Report>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
31
components/pages/reports/retention/RetentionTable.js
Normal file
31
components/pages/reports/retention/RetentionTable.js
Normal file
|
|
@ -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 (
|
||||||
|
<GridTable data={report?.data || []}>
|
||||||
|
<GridColumn name="date" label={'Date'}>
|
||||||
|
{row => row.date}
|
||||||
|
</GridColumn>
|
||||||
|
<GridColumn name="day" label={'Day'}>
|
||||||
|
{row => row.day}
|
||||||
|
</GridColumn>
|
||||||
|
<GridColumn name="visitors" label={formatMessage(labels.visitors)}>
|
||||||
|
{row => row.visitors}
|
||||||
|
</GridColumn>
|
||||||
|
<GridColumn name="returnVisitors" label={'Return Visitors'}>
|
||||||
|
{row => row.returnVisitors}
|
||||||
|
</GridColumn>
|
||||||
|
<GridColumn name="percentage" label={'Percentage'}>
|
||||||
|
{row => row.percentage}
|
||||||
|
</GridColumn>
|
||||||
|
</GridTable>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RetentionTable;
|
||||||
44
pages/api/reports/retention.ts
Normal file
44
pages/api/reports/retention.ts
Normal file
|
|
@ -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<any, RetentionRequestBody>,
|
||||||
|
res: NextApiResponse<RetentionResponse>,
|
||||||
|
) => {
|
||||||
|
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);
|
||||||
|
};
|
||||||
13
pages/reports/retention.js
Normal file
13
pages/reports/retention.js
Normal file
|
|
@ -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 (
|
||||||
|
<AppLayout title={`${formatMessage(labels.retention)} - ${formatMessage(labels.reports)}`}>
|
||||||
|
<RetentionReport />
|
||||||
|
</AppLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -21,10 +21,10 @@ async function relationalQuery(websiteId: string, filters: QueryFilters & { fiel
|
||||||
return rawQuery(
|
return rawQuery(
|
||||||
`
|
`
|
||||||
select
|
select
|
||||||
event_key as fieldName,
|
event_key as "fieldName",
|
||||||
data_type as dataType,
|
data_type as "dataType",
|
||||||
string_value as fieldValue,
|
string_value as "fieldValue",
|
||||||
count(*) as total
|
count(*) as "total"
|
||||||
from event_data
|
from event_data
|
||||||
where website_id = {{websiteId::uuid}}
|
where website_id = {{websiteId::uuid}}
|
||||||
and created_at between {{startDate}} and {{endDate}}
|
and created_at between {{startDate}} and {{endDate}}
|
||||||
|
|
|
||||||
166
queries/analytics/reports/getRetention.ts
Normal file
166
queries/analytics/reports/getRetention.ts
Normal file
|
|
@ -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,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -12,6 +12,7 @@ export * from './analytics/eventData/getEventDataFields';
|
||||||
export * from './analytics/eventData/getEventDataUsage';
|
export * from './analytics/eventData/getEventDataUsage';
|
||||||
export * from './analytics/events/saveEvent';
|
export * from './analytics/events/saveEvent';
|
||||||
export * from './analytics/reports/getFunnel';
|
export * from './analytics/reports/getFunnel';
|
||||||
|
export * from './analytics/reports/getRetention';
|
||||||
export * from './analytics/reports/getInsights';
|
export * from './analytics/reports/getInsights';
|
||||||
export * from './analytics/pageviews/getPageviewMetrics';
|
export * from './analytics/pageviews/getPageviewMetrics';
|
||||||
export * from './analytics/pageviews/getPageviewStats';
|
export * from './analytics/pageviews/getPageviewStats';
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue