diff --git a/ignore-build-step.sh b/ignore-build-step.sh deleted file mode 100644 index da63c205..00000000 --- a/ignore-build-step.sh +++ /dev/null @@ -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 \ No newline at end of file diff --git a/src/app/(main)/reports/create/ReportTemplates.tsx b/src/app/(main)/reports/create/ReportTemplates.tsx index fdf5c5f5..0777cc1f 100644 --- a/src/app/(main)/reports/create/ReportTemplates.tsx +++ b/src/app/(main)/reports/create/ReportTemplates.tsx @@ -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: , }, + { + title: formatMessage(labels.journey), + description: formatMessage(labels.journeyDescription), + url: renderTeamUrl('/reports/journey'), + icon: , + }, ]; return ( diff --git a/src/app/(main)/reports/journey/JourneyParameters.tsx b/src/app/(main)/reports/journey/JourneyParameters.tsx new file mode 100644 index 00000000..b0544168 --- /dev/null +++ b/src/app/(main)/reports/journey/JourneyParameters.tsx @@ -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 ( +
+ + + + {formatMessage(labels.runQuery)} + + + + ); +} + +export default JourneyParameters; diff --git a/src/app/(main)/reports/journey/JourneyReport.tsx b/src/app/(main)/reports/journey/JourneyReport.tsx new file mode 100644 index 00000000..7b8927b4 --- /dev/null +++ b/src/app/(main)/reports/journey/JourneyReport.tsx @@ -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 ( + + } /> + + + + + + + + ); +} diff --git a/src/app/(main)/reports/journey/JourneyReportPage.tsx b/src/app/(main)/reports/journey/JourneyReportPage.tsx new file mode 100644 index 00000000..0f4b78ca --- /dev/null +++ b/src/app/(main)/reports/journey/JourneyReportPage.tsx @@ -0,0 +1,5 @@ +import JourneyReport from './JourneyReport'; + +export default function JourneyReportPage() { + return ; +} diff --git a/src/app/(main)/reports/journey/JourneyView.module.css b/src/app/(main)/reports/journey/JourneyView.module.css new file mode 100644 index 00000000..fa7cc0b4 --- /dev/null +++ b/src/app/(main)/reports/journey/JourneyView.module.css @@ -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; +} diff --git a/src/app/(main)/reports/journey/JourneyView.tsx b/src/app/(main)/reports/journey/JourneyView.tsx new file mode 100644 index 00000000..6905d74c --- /dev/null +++ b/src/app/(main)/reports/journey/JourneyView.tsx @@ -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
{JSON.stringify(data)}
; +} diff --git a/src/app/(main)/reports/journey/page.tsx b/src/app/(main)/reports/journey/page.tsx new file mode 100644 index 00000000..447747cc --- /dev/null +++ b/src/app/(main)/reports/journey/page.tsx @@ -0,0 +1,10 @@ +import { Metadata } from 'next'; +import JourneyReportPage from './JourneyReportPage'; + +export default function () { + return ; +} + +export const metadata: Metadata = { + title: 'Journey Report', +}; diff --git a/src/app/(main)/reports/utm/UTMView.tsx b/src/app/(main)/reports/utm/UTMView.tsx index e59b60eb..f10a68d8 100644 --- a/src/app/(main)/reports/utm/UTMView.tsx +++ b/src/app/(main)/reports/utm/UTMView.tsx @@ -34,6 +34,7 @@ export default function UTMView() { { data: items.map(({ value }) => value), backgroundColor: CHART_COLORS, + borderWidth: 0, }, ], }; diff --git a/src/app/(main)/settings/websites/[websiteId]/WebsiteSettings.tsx b/src/app/(main)/settings/websites/[websiteId]/WebsiteSettings.tsx index 1a92f1f1..11f662b1 100644 --- a/src/app/(main)/settings/websites/[websiteId]/WebsiteSettings.tsx +++ b/src/app/(main)/settings/websites/[websiteId]/WebsiteSettings.tsx @@ -61,7 +61,7 @@ export function WebsiteSettings({ {tab === 'details' && } {tab === 'tracking' && } - {tab === 'share' && } + {tab === 'share' && } {tab === 'data' && } ); diff --git a/src/assets/path.svg b/src/assets/path.svg new file mode 100644 index 00000000..29501565 --- /dev/null +++ b/src/assets/path.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/charts/Chart.tsx b/src/components/charts/Chart.tsx index 40829cac..d5d22874 100644 --- a/src/components/charts/Chart.tsx +++ b/src/components/charts/Chart.tsx @@ -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; } diff --git a/src/components/messages.ts b/src/components/messages.ts index 1413549f..238ebf52 100644 --- a/src/components/messages.ts +++ b/src/components/messages.ts @@ -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({ diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 697a4836..03862660 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -115,6 +115,7 @@ export const REPORT_TYPES = { insights: 'insights', retention: 'retention', utm: 'utm', + journey: 'journey', } as const; export const REPORT_PARAMETERS = { diff --git a/src/pages/api/reports/[reportId].ts b/src/pages/api/reports/[reportId].ts index be2db82f..3a7c4c53 100644 --- a/src/pages/api/reports/[reportId].ts +++ b/src/pages/api/reports/[reportId].ts @@ -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), diff --git a/src/pages/api/reports/journey.ts b/src/pages/api/reports/journey.ts new file mode 100644 index 00000000..84246f05 --- /dev/null +++ b/src/pages/api/reports/journey.ts @@ -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, + 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); +}; diff --git a/src/queries/analytics/reports/getJourney.ts b/src/queries/analytics/reports/getJourney.ts new file mode 100644 index 00000000..088f7ee8 --- /dev/null +++ b/src/queries/analytics/reports/getJourney.ts @@ -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, + }, + ); +} diff --git a/src/queries/index.ts b/src/queries/index.ts index f0002881..8cef080a 100644 --- a/src/queries/index.ts +++ b/src/queries/index.ts @@ -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';