mirror of
https://github.com/kremalicious/umami.git
synced 2025-02-01 12:29:35 +01:00
Bootstrap User Journey report.
This commit is contained in:
parent
37eb157ab5
commit
76cab03bb2
@ -1,14 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
echo "VERCEL_GIT_COMMIT_REF: $VERCEL_GIT_COMMIT_REF"
|
||||
|
||||
if [[ "$VERCEL_GIT_COMMIT_REF" != "analytics" ]] ; then
|
||||
# Proceed with the build
|
||||
echo "✅ - Build can proceed"
|
||||
exit 1;
|
||||
|
||||
else
|
||||
# Don't build
|
||||
echo "🛑 - Build cancelled"
|
||||
exit 0;
|
||||
fi
|
@ -6,6 +6,7 @@ import Lightbulb from 'assets/lightbulb.svg';
|
||||
import Magnet from 'assets/magnet.svg';
|
||||
import Tag from 'assets/tag.svg';
|
||||
import Target from 'assets/target.svg';
|
||||
import Path from 'assets/path.svg';
|
||||
import styles from './ReportTemplates.module.css';
|
||||
import { useMessages, useTeamUrl } from 'components/hooks';
|
||||
|
||||
@ -44,6 +45,12 @@ export function ReportTemplates({ showHeader = true }: { showHeader?: boolean })
|
||||
url: renderTeamUrl('/reports/goals'),
|
||||
icon: <Target />,
|
||||
},
|
||||
{
|
||||
title: formatMessage(labels.journey),
|
||||
description: formatMessage(labels.journeyDescription),
|
||||
url: renderTeamUrl('/reports/journey'),
|
||||
icon: <Path />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
|
36
src/app/(main)/reports/journey/JourneyParameters.tsx
Normal file
36
src/app/(main)/reports/journey/JourneyParameters.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
import { useContext } from 'react';
|
||||
import { useMessages } from 'components/hooks';
|
||||
import { Form, FormButtons, SubmitButton } from 'react-basics';
|
||||
import { ReportContext } from '../[reportId]/Report';
|
||||
import BaseParameters from '../[reportId]/BaseParameters';
|
||||
|
||||
export function JourneyParameters() {
|
||||
const { report, runReport, isRunning } = useContext(ReportContext);
|
||||
const { formatMessage, labels } = useMessages();
|
||||
|
||||
const { id, parameters } = report || {};
|
||||
const { websiteId, dateRange } = parameters || {};
|
||||
const queryDisabled = !websiteId || !dateRange;
|
||||
|
||||
const handleSubmit = (data: any, e: any) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
if (!queryDisabled) {
|
||||
runReport(data);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Form values={parameters} onSubmit={handleSubmit} preventSubmit={true}>
|
||||
<BaseParameters showDateSelect={true} allowWebsiteSelect={!id} />
|
||||
<FormButtons>
|
||||
<SubmitButton variant="primary" disabled={queryDisabled} isLoading={isRunning}>
|
||||
{formatMessage(labels.runQuery)}
|
||||
</SubmitButton>
|
||||
</FormButtons>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
export default JourneyParameters;
|
28
src/app/(main)/reports/journey/JourneyReport.tsx
Normal file
28
src/app/(main)/reports/journey/JourneyReport.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
'use client';
|
||||
import Report from '../[reportId]/Report';
|
||||
import ReportHeader from '../[reportId]/ReportHeader';
|
||||
import ReportMenu from '../[reportId]/ReportMenu';
|
||||
import ReportBody from '../[reportId]/ReportBody';
|
||||
import JourneyParameters from './JourneyParameters';
|
||||
import JourneyView from './JourneyView';
|
||||
import Path from 'assets/path.svg';
|
||||
import { REPORT_TYPES } from 'lib/constants';
|
||||
|
||||
const defaultParameters = {
|
||||
type: REPORT_TYPES.journey,
|
||||
parameters: {},
|
||||
};
|
||||
|
||||
export default function JourneyReport({ reportId }: { reportId?: string }) {
|
||||
return (
|
||||
<Report reportId={reportId} defaultParameters={defaultParameters}>
|
||||
<ReportHeader icon={<Path />} />
|
||||
<ReportMenu>
|
||||
<JourneyParameters />
|
||||
</ReportMenu>
|
||||
<ReportBody>
|
||||
<JourneyView />
|
||||
</ReportBody>
|
||||
</Report>
|
||||
);
|
||||
}
|
5
src/app/(main)/reports/journey/JourneyReportPage.tsx
Normal file
5
src/app/(main)/reports/journey/JourneyReportPage.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import JourneyReport from './JourneyReport';
|
||||
|
||||
export default function JourneyReportPage() {
|
||||
return <JourneyReport />;
|
||||
}
|
14
src/app/(main)/reports/journey/JourneyView.module.css
Normal file
14
src/app/(main)/reports/journey/JourneyView.module.css
Normal file
@ -0,0 +1,14 @@
|
||||
.title {
|
||||
font-size: 24px;
|
||||
line-height: 36px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 50% 50%;
|
||||
gap: 20px;
|
||||
border-bottom: 1px solid var(--base300);
|
||||
padding-bottom: 30px;
|
||||
margin-bottom: 30px;
|
||||
}
|
13
src/app/(main)/reports/journey/JourneyView.tsx
Normal file
13
src/app/(main)/reports/journey/JourneyView.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import { useContext } from 'react';
|
||||
import { ReportContext } from '../[reportId]/Report';
|
||||
|
||||
export default function JourneyView() {
|
||||
const { report } = useContext(ReportContext);
|
||||
const { data } = report || {};
|
||||
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <div>{JSON.stringify(data)}</div>;
|
||||
}
|
10
src/app/(main)/reports/journey/page.tsx
Normal file
10
src/app/(main)/reports/journey/page.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import { Metadata } from 'next';
|
||||
import JourneyReportPage from './JourneyReportPage';
|
||||
|
||||
export default function () {
|
||||
return <JourneyReportPage />;
|
||||
}
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Journey Report',
|
||||
};
|
@ -34,6 +34,7 @@ export default function UTMView() {
|
||||
{
|
||||
data: items.map(({ value }) => value),
|
||||
backgroundColor: CHART_COLORS,
|
||||
borderWidth: 0,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
@ -61,7 +61,7 @@ export function WebsiteSettings({
|
||||
</Tabs>
|
||||
{tab === 'details' && <WebsiteEditForm websiteId={websiteId} onSave={handleSave} />}
|
||||
{tab === 'tracking' && <TrackingCode websiteId={websiteId} />}
|
||||
{tab === 'share' && <ShareUrl websiteId={websiteId} onSave={handleSave} />}
|
||||
{tab === 'share' && <ShareUrl onSave={handleSave} />}
|
||||
{tab === 'data' && <WebsiteData websiteId={websiteId} onSave={handleSave} />}
|
||||
</>
|
||||
);
|
||||
|
1
src/assets/path.svg
Normal file
1
src/assets/path.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" fill="none" viewBox="0 0 64 64"><path fill="#000" d="m56.4 47.6-6-6c-.8-.8-2-.8-2.8 0s-.8 2 0 2.8l2.6 2.6H18.5c-3.6 0-6.5-2.9-6.5-6.5s2.9-6.5 6.5-6.5h27C51.3 34 56 29.3 56 23.5S51.3 13 45.5 13H22.7c-.9-3.4-4-6-7.7-6-4.4 0-8 3.6-8 8s3.6 8 8 8c3.7 0 6.8-2.6 7.7-6h22.8c3.6 0 6.5 2.9 6.5 6.5S49.1 30 45.5 30h-27C12.7 30 8 34.7 8 40.5S12.7 51 18.5 51h31.7l-2.6 2.6c-.8.8-.8 2 0 2.8.4.4.9.6 1.4.6s1-.2 1.4-.6l6-6c.8-.8.8-2 0-2.8M15 19c-2.2 0-4-1.8-4-4s1.8-4 4-4 4 1.8 4 4-1.8 4-4 4"/></svg>
|
After Width: | Height: | Size: 550 B |
@ -1,7 +1,7 @@
|
||||
import { useState, useRef, useEffect, useMemo, ReactNode } from 'react';
|
||||
import { Loading } from 'react-basics';
|
||||
import classNames from 'classnames';
|
||||
import ChartJS, { LegendItem } from 'chart.js/auto';
|
||||
import ChartJS, { LegendItem, ChartOptions } from 'chart.js/auto';
|
||||
import HoverTooltip from 'components/common/HoverTooltip';
|
||||
import Legend from 'components/metrics/Legend';
|
||||
import { DEFAULT_ANIMATION_DURATION } from 'lib/constants';
|
||||
@ -17,7 +17,7 @@ export interface ChartProps {
|
||||
onUpdate?: (chart: any) => void;
|
||||
onTooltip?: (model: any) => void;
|
||||
className?: string;
|
||||
chartOptions?: { [key: string]: any };
|
||||
chartOptions?: ChartOptions;
|
||||
tooltip?: ReactNode;
|
||||
}
|
||||
|
||||
|
@ -239,9 +239,14 @@ export const labels = defineMessages({
|
||||
goals: { id: 'label.goals', defaultMessage: 'Goals' },
|
||||
goalsDescription: {
|
||||
id: 'label.goals-description',
|
||||
defaultMessage: 'Track your goals for pageviews or events.',
|
||||
defaultMessage: 'Track your goals for pageviews and events.',
|
||||
},
|
||||
count: { id: 'label.count', defaultMessage: 'Count' },
|
||||
journey: { id: 'label.journey', defaultMessage: 'Journey' },
|
||||
journeyDescription: {
|
||||
id: 'label.journey-description',
|
||||
defaultMessage: 'Understand how users nagivate through your website.',
|
||||
},
|
||||
});
|
||||
|
||||
export const messages = defineMessages({
|
||||
|
@ -115,6 +115,7 @@ export const REPORT_TYPES = {
|
||||
insights: 'insights',
|
||||
retention: 'retention',
|
||||
utm: 'utm',
|
||||
journey: 'journey',
|
||||
} as const;
|
||||
|
||||
export const REPORT_PARAMETERS = {
|
||||
|
@ -27,7 +27,7 @@ const schema: YupRequest = {
|
||||
websiteId: yup.string().uuid().required(),
|
||||
type: yup
|
||||
.string()
|
||||
.matches(/funnel|insights|retention|utm|goals/i)
|
||||
.matches(/funnel|insights|retention|utm|goals|journey/i)
|
||||
.required(),
|
||||
name: yup.string().max(200).required(),
|
||||
description: yup.string().max(500),
|
||||
|
54
src/pages/api/reports/journey.ts
Normal file
54
src/pages/api/reports/journey.ts
Normal file
@ -0,0 +1,54 @@
|
||||
import { canViewWebsite } from 'lib/auth';
|
||||
import { useAuth, useCors, useValidate } from 'lib/middleware';
|
||||
import { NextApiRequestQueryBody } from 'lib/types';
|
||||
import { NextApiResponse } from 'next';
|
||||
import { methodNotAllowed, ok, unauthorized } from 'next-basics';
|
||||
import { getJourney } from 'queries';
|
||||
import * as yup from 'yup';
|
||||
|
||||
export interface RetentionRequestBody {
|
||||
websiteId: string;
|
||||
dateRange: { startDate: string; endDate: string };
|
||||
}
|
||||
|
||||
const schema = {
|
||||
POST: yup.object().shape({
|
||||
websiteId: yup.string().uuid().required(),
|
||||
dateRange: yup
|
||||
.object()
|
||||
.shape({
|
||||
startDate: yup.date().required(),
|
||||
endDate: yup.date().required(),
|
||||
})
|
||||
.required(),
|
||||
}),
|
||||
};
|
||||
|
||||
export default async (
|
||||
req: NextApiRequestQueryBody<any, RetentionRequestBody>,
|
||||
res: NextApiResponse,
|
||||
) => {
|
||||
await useCors(req, res);
|
||||
await useAuth(req, res);
|
||||
await useValidate(schema, 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 getJourney(websiteId, {
|
||||
startDate: new Date(startDate),
|
||||
endDate: new Date(endDate),
|
||||
});
|
||||
|
||||
return ok(res, data);
|
||||
}
|
||||
|
||||
return methodNotAllowed(res);
|
||||
};
|
148
src/queries/analytics/reports/getJourney.ts
Normal file
148
src/queries/analytics/reports/getJourney.ts
Normal file
@ -0,0 +1,148 @@
|
||||
import clickhouse from 'lib/clickhouse';
|
||||
import { CLICKHOUSE, PRISMA, runQuery } from 'lib/db';
|
||||
import prisma from 'lib/prisma';
|
||||
|
||||
export async function getJourney(
|
||||
...args: [
|
||||
websiteId: string,
|
||||
filters: {
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
},
|
||||
]
|
||||
) {
|
||||
return runQuery({
|
||||
[PRISMA]: () => relationalQuery(...args),
|
||||
[CLICKHOUSE]: () => clickhouseQuery(...args),
|
||||
});
|
||||
}
|
||||
|
||||
async function relationalQuery(
|
||||
websiteId: string,
|
||||
filters: {
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
},
|
||||
): Promise<
|
||||
{
|
||||
e1: string;
|
||||
e2: string;
|
||||
e3: string;
|
||||
e4: string;
|
||||
e5: string;
|
||||
count: string;
|
||||
}[]
|
||||
> {
|
||||
const { startDate, endDate } = filters;
|
||||
const { rawQuery } = prisma;
|
||||
|
||||
return rawQuery(
|
||||
`
|
||||
WITH events AS (
|
||||
select distinct
|
||||
session_id,
|
||||
referrer_path,
|
||||
COALESCE(event_name, url_path) event,
|
||||
ROW_NUMBER() OVER (PARTITION BY session_id ORDER BY created_at) AS event_number
|
||||
from website_event
|
||||
where website_id = {{websiteId::uuid}}
|
||||
and created_at between {{startDate}} and {{endDate}}
|
||||
and referrer_path != url_path),
|
||||
sequences as (
|
||||
SELECT s.e1,
|
||||
s.e2,
|
||||
s.e3,
|
||||
s.e4,
|
||||
s.e5,
|
||||
count(*) count
|
||||
FROM (
|
||||
SELECT session_id,
|
||||
MAX(CASE WHEN event_number = 1 THEN event ELSE NULL END) AS e1,
|
||||
MAX(CASE WHEN event_number = 2 THEN event ELSE NULL END) AS e2,
|
||||
MAX(CASE WHEN event_number = 3 THEN event ELSE NULL END) AS e3,
|
||||
MAX(CASE WHEN event_number = 4 THEN event ELSE NULL END) AS e4,
|
||||
MAX(CASE WHEN event_number = 5 THEN event ELSE NULL END) AS e5
|
||||
FROM events
|
||||
group by session_id) s
|
||||
group by s.e1,
|
||||
s.e2,
|
||||
s.e3,
|
||||
s.e4,
|
||||
s.e5)
|
||||
select *
|
||||
from sequences
|
||||
order by count desc
|
||||
limit 100
|
||||
`,
|
||||
{
|
||||
websiteId,
|
||||
startDate,
|
||||
endDate,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function clickhouseQuery(
|
||||
websiteId: string,
|
||||
filters: {
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
},
|
||||
): Promise<
|
||||
{
|
||||
e1: string;
|
||||
e2: string;
|
||||
e3: string;
|
||||
e4: string;
|
||||
e5: string;
|
||||
count: string;
|
||||
}[]
|
||||
> {
|
||||
const { startDate, endDate } = filters;
|
||||
const { rawQuery } = clickhouse;
|
||||
|
||||
return rawQuery(
|
||||
`
|
||||
WITH events AS (
|
||||
select distinct
|
||||
session_id,
|
||||
referrer_path,
|
||||
coalesce(nullIf(event_name, ''), url_path) event,
|
||||
row_number() OVER (PARTITION BY session_id ORDER BY created_at) AS event_number
|
||||
from umami.website_event
|
||||
where website_id = {websiteId:UUID}
|
||||
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
|
||||
and referrer_path != url_path),
|
||||
sequences as (
|
||||
SELECT s.e1,
|
||||
s.e2,
|
||||
s.e3,
|
||||
s.e4,
|
||||
s.e5,
|
||||
count(*) count
|
||||
FROM (
|
||||
SELECT session_id,
|
||||
max(CASE WHEN event_number = 1 THEN event ELSE NULL END) AS e1,
|
||||
max(CASE WHEN event_number = 2 THEN event ELSE NULL END) AS e2,
|
||||
max(CASE WHEN event_number = 3 THEN event ELSE NULL END) AS e3,
|
||||
max(CASE WHEN event_number = 4 THEN event ELSE NULL END) AS e4,
|
||||
max(CASE WHEN event_number = 5 THEN event ELSE NULL END) AS e5
|
||||
FROM events
|
||||
group by session_id) s
|
||||
group by s.e1,
|
||||
s.e2,
|
||||
s.e3,
|
||||
s.e4,
|
||||
s.e5)
|
||||
select *
|
||||
from sequences
|
||||
order by count desc
|
||||
limit 100
|
||||
`,
|
||||
{
|
||||
websiteId,
|
||||
startDate,
|
||||
endDate,
|
||||
},
|
||||
);
|
||||
}
|
@ -12,6 +12,7 @@ export * from './analytics/eventData/getEventDataStats';
|
||||
export * from './analytics/eventData/getEventDataUsage';
|
||||
export * from './analytics/events/saveEvent';
|
||||
export * from './analytics/reports/getFunnel';
|
||||
export * from './analytics/reports/getJourney';
|
||||
export * from './analytics/reports/getRetention';
|
||||
export * from './analytics/reports/getInsights';
|
||||
export * from './analytics/reports/getUTM';
|
||||
|
Loading…
Reference in New Issue
Block a user