Compare commits

...

2 Commits

Author SHA1 Message Date
Brian Cao 4d66d400a9 Fix percentages. 2024-05-05 22:28:18 -07:00
Brian Cao 60e7257656 Goals report CH 2024-05-05 22:15:47 -07:00
20 changed files with 761 additions and 4 deletions

View File

@ -4,6 +4,7 @@ import EventDataReport from '../event-data/EventDataReport';
import InsightsReport from '../insights/InsightsReport';
import RetentionReport from '../retention/RetentionReport';
import UTMReport from '../utm/UTMReport';
import GoalReport from '../goals/GoalsReport';
import { useReport } from 'components/hooks';
const reports = {
@ -12,6 +13,7 @@ const reports = {
insights: InsightsReport,
retention: RetentionReport,
utm: UTMReport,
goals: GoalReport,
};
export default function ReportPage({ reportId }: { reportId: string }) {

View File

@ -37,6 +37,12 @@ export function ReportTemplates({ showHeader = true }: { showHeader?: boolean })
url: renderTeamUrl('/reports/utm'),
icon: <Tag />,
},
{
title: formatMessage(labels.goals),
description: formatMessage(labels.goalsDescription),
url: renderTeamUrl('/reports/goals'),
icon: <Tag />,
},
];
return (

View File

@ -0,0 +1,7 @@
.dropdown {
width: 140px;
}
.input {
width: 200px;
}

View File

@ -0,0 +1,95 @@
import { useMessages } from 'components/hooks';
import { useState } from 'react';
import { Button, Dropdown, Flexbox, FormRow, Item, TextField } from 'react-basics';
import styles from './GoalsAddForm.module.css';
export function GoalsAddForm({
type: defaultType = 'url',
value: defaultValue = '',
goal: defaultGoal = 10,
onChange,
}: {
type?: string;
value?: string;
goal?: number;
onChange?: (step: { type: string; value: string; goal: number }) => void;
}) {
const [type, setType] = useState(defaultType);
const [value, setValue] = useState(defaultValue);
const [goal, setGoal] = useState(defaultGoal);
const { formatMessage, labels } = useMessages();
const items = [
{ label: formatMessage(labels.url), value: 'url' },
{ label: formatMessage(labels.event), value: 'event' },
];
const isDisabled = !type || !value;
const handleSave = () => {
onChange({ type, value, goal });
setValue('');
setGoal(10);
};
const handleChange = (e, set) => {
set(e.target.value);
};
const handleKeyDown = e => {
if (e.key === 'Enter') {
e.stopPropagation();
handleSave();
}
};
const renderTypeValue = (value: any) => {
return items.find(item => item.value === value)?.label;
};
return (
<Flexbox direction="column" gap={10}>
<FormRow label={formatMessage(defaultValue ? labels.update : labels.add)}>
<Flexbox gap={10}>
<Dropdown
className={styles.dropdown}
items={items}
value={type}
renderValue={renderTypeValue}
onChange={(value: any) => setType(value)}
>
{({ value, label }) => {
return <Item key={value}>{label}</Item>;
}}
</Dropdown>
<TextField
className={styles.input}
value={value}
onChange={e => handleChange(e, setValue)}
autoFocus={true}
autoComplete="off"
onKeyDown={handleKeyDown}
/>
</Flexbox>
</FormRow>
<FormRow label={formatMessage(labels.goal)}>
<Flexbox gap={10}>
<TextField
className={styles.input}
value={goal?.toString()}
onChange={e => handleChange(e, setGoal)}
autoFocus={true}
autoComplete="off"
onKeyDown={handleKeyDown}
/>
.
</Flexbox>
</FormRow>
<FormRow>
<Button variant="primary" onClick={handleSave} disabled={isDisabled}>
{formatMessage(defaultValue ? labels.update : labels.add)}
</Button>
</FormRow>
</Flexbox>
);
}
export default GoalsAddForm;

View File

@ -0,0 +1,87 @@
.chart {
display: grid;
}
.num {
display: flex;
align-items: center;
justify-content: center;
border-radius: 100%;
width: 50px;
height: 50px;
font-size: 16px;
font-weight: 700;
color: var(--base800);
background: var(--base100);
z-index: 1;
}
.step {
display: grid;
grid-template-columns: max-content 1fr;
column-gap: 30px;
position: relative;
padding-bottom: 60px;
}
.card {
display: grid;
gap: 20px;
margin-top: 14px;
}
.header {
display: flex;
flex-direction: column;
gap: 20px;
}
.bar {
display: flex;
align-items: center;
justify-content: flex-end;
background: var(--base900);
height: 30px;
border-radius: 5px;
overflow: hidden;
position: relative;
}
.label {
color: var(--base600);
font-weight: 700;
text-transform: uppercase;
}
.track {
background-color: var(--base100);
border-radius: 5px;
}
.item {
font-size: 20px;
color: var(--base900);
font-weight: 700;
}
.metric {
color: var(--base700);
display: flex;
justify-content: space-between;
gap: 10px;
margin: 10px 0;
text-transform: lowercase;
}
.visitors {
color: var(--base900);
font-size: 24px;
font-weight: 900;
margin-right: 10px;
}
.percent {
font-size: 20px;
font-weight: 700;
align-self: flex-end;
}

View File

@ -0,0 +1,52 @@
import { useContext } from 'react';
import classNames from 'classnames';
import { useMessages } from 'components/hooks';
import { ReportContext } from '../[reportId]/Report';
import { formatLongNumber } from 'lib/format';
import styles from './GoalsChart.module.css';
export function GoalsChart({ className }: { className?: string; isLoading?: boolean }) {
const { report } = useContext(ReportContext);
const { formatMessage, labels } = useMessages();
const { data } = report || {};
return (
<div className={classNames(styles.chart, className)}>
{data?.map(({ type, value, goal, result }, index: number) => {
return (
<div key={index} className={styles.step}>
<div className={styles.num}>{index + 1}</div>
<div className={styles.card}>
<div className={styles.header}>
<span className={styles.label}>
{formatMessage(type === 'url' ? labels.viewedPage : labels.triggeredEvent)}
</span>
<span className={styles.item}>{value}</span>
</div>
<div className={styles.metric}>
<div>
<span className={styles.visitors}>{formatLongNumber(result)}</span>
{formatMessage(labels.visitors)}
</div>
<div>
<span className={styles.visitors}>{formatLongNumber(goal)}</span>
{formatMessage(labels.goal)}
</div>
<div className={styles.percent}>{((result / goal) * 100).toFixed(2)}%</div>
</div>
<div className={styles.track}>
<div
className={styles.bar}
style={{ width: `${result > goal ? 100 : (result / goal) * 100}%` }}
></div>
</div>
</div>
</div>
);
})}
</div>
);
}
export default GoalsChart;

View File

@ -0,0 +1,26 @@
.item {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
}
.type {
color: var(--base700);
}
.value {
display: flex;
align-self: center;
gap: 20px;
}
.goal {
color: var(--blue900);
background-color: var(--blue100);
font-size: 12px;
font-weight: 900;
padding: 2px 8px;
border-radius: 5px;
white-space: nowrap;
}

View File

@ -0,0 +1,123 @@
import { useMessages } from 'components/hooks';
import Icons from 'components/icons';
import { formatNumber } from 'lib/format';
import { useContext } from 'react';
import {
Button,
Form,
FormButtons,
FormRow,
Icon,
Popup,
PopupTrigger,
SubmitButton,
} from 'react-basics';
import BaseParameters from '../[reportId]/BaseParameters';
import ParameterList from '../[reportId]/ParameterList';
import PopupForm from '../[reportId]/PopupForm';
import { ReportContext } from '../[reportId]/Report';
import GoalsAddForm from './GoalsAddForm';
import styles from './GoalsParameters.module.css';
export function GoalsParameters() {
const { report, runReport, updateReport, isRunning } = useContext(ReportContext);
const { formatMessage, labels } = useMessages();
const { id, parameters } = report || {};
const { websiteId, dateRange, goals } = parameters || {};
const queryDisabled = !websiteId || !dateRange || goals?.length < 1;
const handleSubmit = (data: any, e: any) => {
e.stopPropagation();
e.preventDefault();
if (!queryDisabled) {
runReport(data);
}
};
const handleAddGoals = (goal: { type: string; value: string }) => {
updateReport({ parameters: { goals: parameters.goals.concat(goal) } });
};
const handleUpdateGoals = (
close: () => void,
index: number,
goal: { type: string; value: string },
) => {
const goals = [...parameters.goals];
goals[index] = goal;
updateReport({ parameters: { goals } });
close();
};
const handleRemoveGoals = (index: number) => {
const goals = [...parameters.goals];
delete goals[index];
updateReport({ parameters: { goals: goals.filter(n => n) } });
};
const AddGoalsButton = () => {
return (
<PopupTrigger>
<Button>
<Icon>
<Icons.Plus />
</Icon>
</Button>
<Popup alignment="start">
<PopupForm>
<GoalsAddForm onChange={handleAddGoals} />
</PopupForm>
</Popup>
</PopupTrigger>
);
};
return (
<Form values={parameters} onSubmit={handleSubmit} preventSubmit={true}>
<BaseParameters allowWebsiteSelect={!id} />
<FormRow label={formatMessage(labels.goals)} action={<AddGoalsButton />}>
<ParameterList>
{goals.map((goal: { type: string; value: string; goal: number }, index: number) => {
return (
<PopupTrigger key={index}>
<ParameterList.Item
className={styles.item}
onRemove={() => handleRemoveGoals(index)}
>
<div className={styles.value}>
<div className={styles.type}>
<Icon>{goal.type === 'url' ? <Icons.Eye /> : <Icons.Bolt />}</Icon>
</div>
<div>{goal.value}</div>
<div className={styles.goal}>{formatNumber(goal.goal)}</div>
</div>
</ParameterList.Item>
<Popup alignment="start">
{(close: () => void) => (
<PopupForm>
<GoalsAddForm
type={goal.type}
value={goal.value}
goal={goal.goal}
onChange={handleUpdateGoals.bind(null, close, index)}
/>
</PopupForm>
)}
</Popup>
</PopupTrigger>
);
})}
</ParameterList>
</FormRow>
<FormButtons>
<SubmitButton variant="primary" disabled={queryDisabled} isLoading={isRunning}>
{formatMessage(labels.runQuery)}
</SubmitButton>
</FormButtons>
</Form>
);
}
export default GoalsParameters;

View File

@ -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;
}

View File

@ -0,0 +1,27 @@
import GoalsChart from './GoalsChart';
import GoalsParameters from './GoalsParameters';
import Report from '../[reportId]/Report';
import ReportHeader from '../[reportId]/ReportHeader';
import ReportMenu from '../[reportId]/ReportMenu';
import ReportBody from '../[reportId]/ReportBody';
import Goals from 'assets/funnel.svg';
import { REPORT_TYPES } from 'lib/constants';
const defaultParameters = {
type: REPORT_TYPES.goals,
parameters: { goals: [] },
};
export default function GoalsReport({ reportId }: { reportId?: string }) {
return (
<Report reportId={reportId} defaultParameters={defaultParameters}>
<ReportHeader icon={<Goals />} />
<ReportMenu>
<GoalsParameters />
</ReportMenu>
<ReportBody>
<GoalsChart />
</ReportBody>
</Report>
);
}

View File

@ -0,0 +1,6 @@
'use client';
import GoalReport from './GoalsReport';
export default function GoalReportPage() {
return <GoalReport />;
}

View File

@ -0,0 +1,10 @@
import GoalsReportPage from './GoalsReportPage';
import { Metadata } from 'next';
export default function () {
return <GoalsReportPage />;
}
export const metadata: Metadata = {
title: 'Goals Report',
};

View File

@ -6,5 +6,5 @@ export default function () {
}
export const metadata: Metadata = {
title: 'UTM Report',
title: 'Goals Report',
};

View File

@ -235,6 +235,13 @@ export const labels = defineMessages({
},
steps: { id: 'label.steps', defaultMessage: 'Steps' },
addStep: { id: 'label.add-step', defaultMessage: 'Add step' },
goal: { id: 'label.goal', defaultMessage: 'Goal' },
goals: { id: 'label.goals', defaultMessage: 'Goals' },
goalsDescription: {
id: 'label.goals-description',
defaultMessage: 'Track your goals for pageviews or events.',
},
count: { id: 'label.count', defaultMessage: 'Count' },
});
export const messages = defineMessages({

View File

@ -119,7 +119,10 @@ async function parseFilters(websiteId: string, filters: QueryFilters = {}, optio
};
}
async function rawQuery(query: string, params: Record<string, unknown> = {}): Promise<unknown> {
async function rawQuery<T = unknown>(
query: string,
params: Record<string, unknown> = {},
): Promise<T> {
if (process.env.LOG_QUERY) {
log('QUERY:\n', query);
log('PARAMETERS:\n', params);

View File

@ -111,6 +111,7 @@ export const DATA_TYPES = {
export const REPORT_TYPES = {
funnel: 'funnel',
goals: 'goals',
insights: 'insights',
retention: 'retention',
utm: 'utm',

View File

@ -27,7 +27,7 @@ const schema: YupRequest = {
websiteId: yup.string().uuid().required(),
type: yup
.string()
.matches(/funnel|insights|retention|utm/i)
.matches(/funnel|insights|retention|utm|goals/i)
.required(),
name: yup.string().max(200).required(),
description: yup.string().max(500),

View File

@ -0,0 +1,70 @@
import { canViewWebsite } from 'lib/auth';
import { useAuth, useCors, useValidate } from 'lib/middleware';
import { NextApiRequestQueryBody } from 'lib/types';
import { TimezoneTest } from 'lib/yup';
import { NextApiResponse } from 'next';
import { methodNotAllowed, ok, unauthorized } from 'next-basics';
import { getGoals } from 'queries/analytics/reports/getGoals';
import * as yup from 'yup';
export interface RetentionRequestBody {
websiteId: string;
dateRange: { startDate: string; endDate: string; timezone: string };
goals: { type: string; value: string; goal: number }[];
}
const schema = {
POST: yup.object().shape({
websiteId: yup.string().uuid().required(),
dateRange: yup
.object()
.shape({
startDate: yup.date().required(),
endDate: yup.date().required(),
timezone: TimezoneTest,
})
.required(),
goals: yup
.array()
.of(
yup.object().shape({
type: yup.string().required(),
value: yup.string().required(),
goal: yup.number().required(),
}),
)
.min(1)
.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 },
goals,
} = req.body;
if (!(await canViewWebsite(req.auth, websiteId))) {
return unauthorized(res);
}
const data = await getGoals(websiteId, {
startDate: new Date(startDate),
endDate: new Date(endDate),
goals,
});
return ok(res, data);
}
return methodNotAllowed(res);
};

View File

@ -27,7 +27,7 @@ const schema = {
name: yup.string().max(200).required(),
type: yup
.string()
.matches(/funnel|insights|retention|utm/i)
.matches(/funnel|insights|retention|utm|goals/i)
.required(),
description: yup.string().max(500),
parameters: yup

View File

@ -0,0 +1,225 @@
import clickhouse from 'lib/clickhouse';
import { CLICKHOUSE, PRISMA, runQuery } from 'lib/db';
import prisma from 'lib/prisma';
export async function getGoals(
...args: [
websiteId: string,
criteria: {
startDate: Date;
endDate: Date;
goals: { type: string; value: string; goal: number }[];
},
]
) {
return runQuery({
[PRISMA]: () => relationalQuery(...args),
[CLICKHOUSE]: () => clickhouseQuery(...args),
});
}
async function relationalQuery(
websiteId: string,
criteria: {
startDate: Date;
endDate: Date;
goals: { type: string; value: string; goal: number }[];
},
): Promise<any> {
const { startDate, endDate, goals } = criteria;
const { rawQuery } = prisma;
const hasUrl = goals.some(a => a.type === 'url');
const hasEvent = goals.some(a => a.type === 'event');
function getParameters(goals: { type: string; value: string; goal: number }[]) {
const urls = goals
.filter(a => a.type === 'url')
.reduce((acc, cv, i) => {
acc[`${cv.type}${i}`] = cv.value;
return acc;
}, {});
const events = goals
.filter(a => a.type === 'event')
.reduce((acc, cv, i) => {
acc[`${cv.type}${i}`] = cv.value;
return acc;
}, {});
return {
urls: { ...urls, startDate, endDate, websiteId },
events: { ...events, startDate, endDate, websiteId },
};
}
function getColumns(goals: { type: string; value: string; goal: number }[]) {
const urls = goals
.filter(a => a.type === 'url')
.map((a, i) => `COUNT(CASE WHEN url_path = {{url${i}}} THEN 1 END) AS URL${i}`)
.join('\n');
const events = goals
.filter(a => a.type === 'event')
.map((a, i) => `COUNT(CASE WHEN url_path = {{event${i}}} THEN 1 END) AS EVENT${i}`)
.join('\n');
return { urls, events };
}
function getWhere(goals: { type: string; value: string; goal: number }[]) {
const urls = goals
.filter(a => a.type === 'url')
.map((a, i) => `{{url${i}}}`)
.join(',');
const events = goals
.filter(a => a.type === 'event')
.map((a, i) => `{{event${i}}}`)
.join(',');
return { urls: `and url_path in (${urls})`, events: `and event_name in (${events})` };
}
const parameters = getParameters(goals);
const columns = getColumns(goals);
const where = getWhere(goals);
const urls = hasUrl
? await rawQuery(
`
select
${columns.urls}
from website_event
where websiteId = {{websiteId::uuid}}
${where.urls}
and created_at between {{startDate}} and {{endDate}}
`,
parameters.urls,
)
: [];
const events = hasEvent
? await rawQuery(
`
select
${columns.events}
from website_event
where websiteId = {{websiteId::uuid}}
${where.events}
and created_at between {{startDate}} and {{endDate}}
`,
parameters.events,
)
: [];
return [...urls, ...events];
}
async function clickhouseQuery(
websiteId: string,
criteria: {
startDate: Date;
endDate: Date;
goals: { type: string; value: string; goal: number }[];
},
): Promise<{ type: string; value: string; goal: number; result: number }[]> {
const { startDate, endDate, goals } = criteria;
const { rawQuery } = clickhouse;
const urls = goals.filter(a => a.type === 'url');
const events = goals.filter(a => a.type === 'event');
const hasUrl = urls.length > 0;
const hasEvent = events.length > 0;
function getParameters(
urls: { type: string; value: string; goal: number }[],
events: { type: string; value: string; goal: number }[],
) {
const urlParam = urls.reduce((acc, cv, i) => {
acc[`${cv.type}${i}`] = cv.value;
return acc;
}, {});
const eventParam = events.reduce((acc, cv, i) => {
acc[`${cv.type}${i}`] = cv.value;
return acc;
}, {});
return {
urls: { ...urlParam, startDate, endDate, websiteId },
events: { ...eventParam, startDate, endDate, websiteId },
};
}
function getColumns(
urls: { type: string; value: string; goal: number }[],
events: { type: string; value: string; goal: number }[],
) {
const urlColumns = urls
.map((a, i) => `countIf(url_path = {url${i}:String}) AS URL${i},`)
.join('\n')
.slice(0, -1);
const eventColumns = events
.map((a, i) => `countIf(event_name = {event${i}:String}) AS EVENT${i}`)
.join('\n')
.slice(0, -1);
return { url: urlColumns, events: eventColumns };
}
function getWhere(
urls: { type: string; value: string; goal: number }[],
events: { type: string; value: string; goal: number }[],
) {
const urlWhere = urls.map((a, i) => `{url${i}:String}`).join(',');
const eventWhere = events.map((a, i) => `{event${i}:String}`).join(',');
return { urls: `and url_path in (${urlWhere})`, events: `and event_name in (${eventWhere})` };
}
const parameters = getParameters(urls, events);
const columns = getColumns(urls, events);
const where = getWhere(urls, events);
const urlResults = hasUrl
? await rawQuery<any>(
`
select
${columns.url}
from website_event
where website_id = {websiteId:UUID}
${where.urls}
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
`,
parameters.urls,
).then(a => {
const results = a[0];
return Object.keys(results).map((key, i) => {
return { ...urls[i], goal: Number(urls[i].goal), result: Number(results[key]) };
});
})
: [];
const eventResults = hasEvent
? await rawQuery<any>(
`
select
${columns.events}
from website_event
where website_id = {websiteId:UUID}
${where.events}
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
`,
parameters.events,
).then(a => {
const results = a[0];
return Object.keys(results).map((key, i) => {
return { ...events[i], goal: Number(events[i].goal), result: Number(results[key]) };
});
})
: [];
return [...urlResults, ...eventResults];
}