From d01aa5cd52dfa00e89a2b49b9477cac456da0159 Mon Sep 17 00:00:00 2001 From: Brian Cao Date: Mon, 8 May 2023 23:46:58 -0700 Subject: [PATCH 1/6] Add funnel queries --- .../migrations/02_report_schema/migration.sql | 18 ++++ db/postgresql/schema.prisma | 28 ++++-- lib/clickhouse.ts | 24 +++++ lib/prisma.ts | 56 +++++++++++ pages/api/reports/funnel.ts | 50 ++++++++++ .../analytics/pageview/getPageviewFunnel.ts | 97 +++++++++++++++++++ queries/index.js | 1 + 7 files changed, 268 insertions(+), 6 deletions(-) create mode 100644 db/postgresql/migrations/02_report_schema/migration.sql create mode 100644 pages/api/reports/funnel.ts create mode 100644 queries/analytics/pageview/getPageviewFunnel.ts diff --git a/db/postgresql/migrations/02_report_schema/migration.sql b/db/postgresql/migrations/02_report_schema/migration.sql new file mode 100644 index 00000000..61f1164d --- /dev/null +++ b/db/postgresql/migrations/02_report_schema/migration.sql @@ -0,0 +1,18 @@ +-- CreateTable +CREATE TABLE "report" ( + "report_id" UUID NOT NULL, + "user_id" UUID NOT NULL, + "report_name" VARCHAR(200) NOT NULL, + "template_name" VARCHAR(200) NOT NULL, + "parameters" VARCHAR(6000) NOT NULL, + "created_at" TIMESTAMPTZ(6) DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(6), + + CONSTRAINT "report_pkey" PRIMARY KEY ("report_id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "report_report_id_key" ON "report"("report_id"); + +-- CreateIndex +CREATE INDEX "report_user_id_idx" ON "report"("user_id"); diff --git a/db/postgresql/schema.prisma b/db/postgresql/schema.prisma index b336bce4..7523ce21 100644 --- a/db/postgresql/schema.prisma +++ b/db/postgresql/schema.prisma @@ -14,11 +14,12 @@ model User { password String @db.VarChar(60) role String @map("role") @db.VarChar(50) createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6) - updatedAt DateTime? @map("updated_at") @updatedAt @db.Timestamptz(6) + updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6) deletedAt DateTime? @map("deleted_at") @db.Timestamptz(6) - website Website[] - teamUser TeamUser[] + website Website[] + teamUser TeamUser[] + ReportTemplate ReportTemplate[] @@map("user") } @@ -53,7 +54,7 @@ model Website { resetAt DateTime? @map("reset_at") @db.Timestamptz(6) userId String? @map("user_id") @db.Uuid createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6) - updatedAt DateTime? @map("updated_at") @updatedAt @db.Timestamptz(6) + updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6) deletedAt DateTime? @map("deleted_at") @db.Timestamptz(6) user User? @relation(fields: [userId], references: [id]) @@ -116,7 +117,7 @@ model Team { name String @db.VarChar(50) accessCode String? @unique @map("access_code") @db.VarChar(50) createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6) - updatedAt DateTime? @map("updated_at") @updatedAt @db.Timestamptz(6) + updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6) teamUser TeamUser[] teamWebsite TeamWebsite[] @@ -131,7 +132,7 @@ model TeamUser { userId String @map("user_id") @db.Uuid role String @map("role") @db.VarChar(50) createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6) - updatedAt DateTime? @map("updated_at") @updatedAt @db.Timestamptz(6) + updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6) team Team @relation(fields: [teamId], references: [id]) user User @relation(fields: [userId], references: [id]) @@ -154,3 +155,18 @@ model TeamWebsite { @@index([websiteId]) @@map("team_website") } + +model ReportTemplate { + id String @id() @unique() @map("report_id") @db.Uuid + userId String @map("user_id") @db.Uuid + reportName String @map("report_name") @db.VarChar(200) + templateName String @map("template_name") @db.VarChar(200) + parameters String @map("parameters") @db.VarChar(6000) + createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6) + + user User @relation(fields: [userId], references: [id]) + + @@index([userId]) + @@map("report") +} diff --git a/lib/clickhouse.ts b/lib/clickhouse.ts index 90cf6088..88922c0f 100644 --- a/lib/clickhouse.ts +++ b/lib/clickhouse.ts @@ -121,6 +121,29 @@ function getFilterQuery(filters = {}, params = {}) { return query.join('\n'); } +function getFunnelQuery(urls: string[]): { + columnsQuery: string; + conditionQuery: string; + urlParams: { [key: string]: string }; +} { + return urls.reduce( + (pv, cv, i) => { + pv.columnsQuery += `\n,url_path = {url${i}:String}${ + i > 0 && urls[i - 1] ? ` AND request_url = {url${i - 1}:String}` : '' + },'`; + pv.conditionQuery += `${i > 0 ? ',' : ''} {url${i}:String}`; + pv.urlParams[`url${i}`] = cv; + + return pv; + }, + { + columnsQuery: '', + conditionQuery: '', + urlParams: {}, + }, + ); +} + function parseFilters(filters: WebsiteMetricFilter = {}, params: any = {}) { return { filterQuery: getFilterQuery(filters, params), @@ -168,6 +191,7 @@ export default { getDateFormat, getBetweenDates, getFilterQuery, + getFunnelQuery, getEventDataFilterQuery, parseFilters, findUnique, diff --git a/lib/prisma.ts b/lib/prisma.ts index 0a10d981..ce2238b6 100644 --- a/lib/prisma.ts +++ b/lib/prisma.ts @@ -32,6 +32,18 @@ function toUuid(): string { } } +function getAddMinutesQuery(field: string, minutes: number) { + const db = getDatabaseType(process.env.DATABASE_URL); + + if (db === POSTGRESQL) { + return `${field} + interval '${minutes} minute'`; + } + + if (db === MYSQL) { + return `DATE_ADD(${field}, interval ${minutes} minute)`; + } +} + function getDateQuery(field: string, unit: string, timezone?: string): string { const db = getDatabaseType(process.env.DATABASE_URL); @@ -122,6 +134,48 @@ function getFilterQuery(filters = {}, params = []): string { return query.join('\n'); } +function getFunnelQuery( + urls: string[], + windowMinutes: number, + initParamLength = 3, +): { + levelQuery: string; + sumQuery: string; + urlFilterQuery: string; +} { + return urls.reduce( + (pv, cv, i) => { + const levelNumber = i + 1; + const start = i > 0 ? ',' : ''; + + pv.levelQuery += `\n + , level${levelNumber} AS ( + select cl.*, + l0.created_at level_${levelNumber}_created_at, + l0.url_path as level_${levelNumber}_url + from level${i} cl + left join level0 l0 + on cl.session_id = l0.session_id + and l0.created_at between cl.level_${levelNumber}_created_at + and ${getAddMinutesQuery(`cl.level_${levelNumber}_created_at`, windowMinutes)} + and l0.referrer_path = $${i + initParamLength} + and l0.url_path = $${i + initParamLength} + )`; + + pv.sumQuery += `\n${start}SUM(CASE WHEN l1_url is not null THEN 1 ELSE 0 END) AS level1`; + + pv.urlFilterQuery += `\n${start}$${levelNumber + initParamLength} `; + + return pv; + }, + { + levelQuery: '', + sumQuery: '', + urlFilterQuery: '', + }, + ); +} + function parseFilters( filters: { [key: string]: any } = {}, params = [], @@ -152,9 +206,11 @@ async function rawQuery(query: string, params: never[] = []): Promise { export default { ...prisma, + getAddMinutesQuery, getDateQuery, getTimestampInterval, getFilterQuery, + getFunnelQuery, getEventDataFilterQuery, toUuid, parseFilters, diff --git a/pages/api/reports/funnel.ts b/pages/api/reports/funnel.ts new file mode 100644 index 00000000..080130de --- /dev/null +++ b/pages/api/reports/funnel.ts @@ -0,0 +1,50 @@ +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 { getPageviewFunnel } from 'queries'; + +export interface FunnelRequestBody { + websiteId: string; + urls: string[]; + window: number; + startAt: number; + endAt: number; +} + +export interface FunnelResponse { + urls: string[]; + window: number; + 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, urls, window, startAt, endAt } = req.body; + if (!(await canViewWebsite(req.auth, websiteId))) { + return unauthorized(res); + } + + const startDate = new Date(+startAt); + const endDate = new Date(+endAt); + + const data = getPageviewFunnel(websiteId, { + startDate, + endDate, + urls, + windowMinutes: window, + }); + + return ok(res); + } + + return methodNotAllowed(res); +}; diff --git a/queries/analytics/pageview/getPageviewFunnel.ts b/queries/analytics/pageview/getPageviewFunnel.ts new file mode 100644 index 00000000..f7751630 --- /dev/null +++ b/queries/analytics/pageview/getPageviewFunnel.ts @@ -0,0 +1,97 @@ +import clickhouse from 'lib/clickhouse'; +import { CLICKHOUSE, PRISMA, runQuery } from 'lib/db'; +import prisma from 'lib/prisma'; + +export async function getPageviewFunnel( + ...args: [ + websiteId: string, + criteria: { + windowMinutes: number; + startDate: Date; + endDate: Date; + urls: string[]; + }, + ] +) { + return runQuery({ + [PRISMA]: () => relationalQuery(...args), + [CLICKHOUSE]: () => clickhouseQuery(...args), + }); +} + +async function relationalQuery( + websiteId: string, + criteria: { + windowMinutes: number; + startDate: Date; + endDate: Date; + urls: string[]; + }, +) { + const { windowMinutes, startDate, endDate, urls } = criteria; + const { rawQuery, getFunnelQuery, toUuid } = prisma; + const { levelQuery, sumQuery, urlFilterQuery } = getFunnelQuery(urls, windowMinutes); + + const params: any = [websiteId, startDate, endDate, ...urls]; + + return rawQuery( + `WITH level0 AS ( + select session_id, url_path, created_at + from website_event + where url_path in (${urlFilterQuery}) + website_event.website_id = $1${toUuid()} + and created_at between $2 and $3 + ),level1 AS ( + select session_id, url_path as level1_url, created_at as level1_created_at + from level0 + )${levelQuery} + + SELECT ${sumQuery} + from level3; + `, + params, + ); +} + +async function clickhouseQuery( + websiteId: string, + criteria: { + windowMinutes: number; + startDate: Date; + endDate: Date; + urls: string[]; + }, +) { + const { windowMinutes, startDate, endDate, urls } = criteria; + const { rawQuery, getBetweenDates, getFunnelQuery } = clickhouse; + const { columnsQuery, conditionQuery, urlParams } = getFunnelQuery(urls); + + const params = { + websiteId, + window: windowMinutes * 60, + ...urlParams, + }; + + return rawQuery( + ` + SELECT level, + count(*) AS count + FROM ( + SELECT session_id, + windowFunnel({window:UInt32}, 'strict_order') + ( + created_at, + ${columnsQuery} + ) AS level + FROM website_event + WHERE website_id = {websiteId:UUID} + and ${getBetweenDates('created_at', startDate, endDate)} + AND (url_path in [${conditionQuery}]) + GROUP BY 1 + ) + GROUP BY level + ORDER BY level ASC; + `, + params, + ); +} diff --git a/queries/index.js b/queries/index.js index d87d5dd5..5c295fff 100644 --- a/queries/index.js +++ b/queries/index.js @@ -8,6 +8,7 @@ export * from './analytics/event/getEvents'; export * from './analytics/eventData/getEventData'; export * from './analytics/eventData/getEventDataUsage'; export * from './analytics/event/saveEvent'; +export * from './analytics/pageview/getPageviewFunnel'; export * from './analytics/pageview/getPageviewMetrics'; export * from './analytics/pageview/getPageviewStats'; export * from './analytics/session/createSession'; From 1130bca1951c136bc9d7926deeb7163619bb8109 Mon Sep 17 00:00:00 2001 From: Brian Cao Date: Thu, 11 May 2023 16:42:58 -0700 Subject: [PATCH 2/6] Funnel form. --- components/input/DateFilter.js | 24 ++- components/metrics/WebsiteChart.js | 7 +- components/pages/reports/FunnelChart.js | 43 +++++ components/pages/reports/FunnelDetails.js | 0 components/pages/reports/FunnelForm.js | 104 ++++++++++++ .../pages/reports/FunnelForm.module.css | 19 +++ components/pages/reports/FunnelPage.js | 26 +++ .../settings/profile/DateRangeSetting.js | 8 +- .../migrations/02_report_schema/migration.sql | 9 +- db/postgresql/schema.prisma | 7 +- lib/clickhouse.ts | 8 +- lib/prisma.ts | 12 +- package.json | 1 + pages/_app.js | 1 + pages/api/reports/funnel.ts | 4 +- pages/reports/funnel.js | 22 +++ .../analytics/pageview/getPageviewFunnel.ts | 57 ++++--- styles/funnelChart.css | 148 ++++++++++++++++++ yarn.lock | 5 + 19 files changed, 460 insertions(+), 45 deletions(-) create mode 100644 components/pages/reports/FunnelChart.js create mode 100644 components/pages/reports/FunnelDetails.js create mode 100644 components/pages/reports/FunnelForm.js create mode 100644 components/pages/reports/FunnelForm.module.css create mode 100644 components/pages/reports/FunnelPage.js create mode 100644 pages/reports/funnel.js create mode 100644 styles/funnelChart.css diff --git a/components/input/DateFilter.js b/components/input/DateFilter.js index b6c1ee72..ecdf9039 100644 --- a/components/input/DateFilter.js +++ b/components/input/DateFilter.js @@ -9,7 +9,7 @@ import useApi from 'hooks/useApi'; import useDateRange from 'hooks/useDateRange'; import useMessages from 'hooks/useMessages'; -export function DateFilter({ websiteId, value, className }) { +export function DateFilter({ websiteId, value, className, onChange, isForm, alignment }) { const { formatMessage, labels } = useMessages(); const { get } = useApi(); const [dateRange, setDateRange] = useDateRange(websiteId); @@ -21,10 +21,26 @@ export function DateFilter({ websiteId, value, className }) { const data = await get(`/websites/${websiteId}`); if (data) { - setDateRange({ value, ...getDateRangeValues(new Date(data.createdAt), Date.now()) }); + const websiteRange = { value, ...getDateRangeValues(new Date(data.createdAt), Date.now()) }; + + if (!isForm) { + setDateRange(websiteRange); + } + + if (onChange) { + onChange(websiteRange); + } } } else if (value !== 'all') { - setDateRange(value); + if (!isForm) { + setDateRange(value); + } + + if (onChange) { + onChange(value); + } + + console.log(value); } } @@ -103,7 +119,7 @@ export function DateFilter({ websiteId, value, className }) { items={options} renderValue={renderValue} value={value} - alignment="end" + alignment={alignment || 'end'} onChange={handleChange} > {({ label, value, divider }) => ( diff --git a/components/metrics/WebsiteChart.js b/components/metrics/WebsiteChart.js index 6614d40f..cab0821e 100644 --- a/components/metrics/WebsiteChart.js +++ b/components/metrics/WebsiteChart.js @@ -107,7 +107,12 @@ export function WebsiteChart({
- +
diff --git a/components/pages/reports/FunnelChart.js b/components/pages/reports/FunnelChart.js new file mode 100644 index 00000000..44c99092 --- /dev/null +++ b/components/pages/reports/FunnelChart.js @@ -0,0 +1,43 @@ +import FunnelGraph from 'funnel-graph-js/dist/js/funnel-graph'; +import { useEffect, useRef } from 'react'; + +export default function FunnelChart() { + const funnel = useRef(null); + + useEffect(() => { + funnel.current.innerHTML = ''; + + const data = { + labels: ['Cv Sent', '1st Interview', '2nd Interview', '3rd Interview', 'Offer'], + subLabels: ['Cv Sent', '1st Interview', '2nd Interview', '3rd Interview', 'Offer'], + colors: [ + ['#FFB178', '#FF78B1', '#FF3C8E'], + ['#FFB178', '#FF78B1', '#FF3C8E'], + ['#A0BBFF', '#EC77FF'], + ['#A0F9FF', '#7795FF'], + ['#FFB178', '#FF78B1', '#FF3C8E'], + ], + values: [[3500], [3300], [2000], [600], [330]], + }; + + const graph = new FunnelGraph({ + container: '.funnel', + gradientDirection: 'horizontal', + data: data, + displayPercent: true, + direction: 'Vertical', + width: 1000, + height: 350, + subLabelValue: 'values', + }); + + graph.draw(); + }, []); + + return ( +
+ FunnelChart +
+
+ ); +} diff --git a/components/pages/reports/FunnelDetails.js b/components/pages/reports/FunnelDetails.js new file mode 100644 index 00000000..e69de29b diff --git a/components/pages/reports/FunnelForm.js b/components/pages/reports/FunnelForm.js new file mode 100644 index 00000000..081105f1 --- /dev/null +++ b/components/pages/reports/FunnelForm.js @@ -0,0 +1,104 @@ +import { useMutation } from '@tanstack/react-query'; +import DateFilter from 'components/input/DateFilter'; +import WebsiteSelect from 'components/input/WebsiteSelect'; +import useApi from 'hooks/useApi'; +import useMessages from 'hooks/useMessages'; +import useUser from 'hooks/useUser'; +import { parseDateRange } from 'lib/date'; +import { useRouter } from 'next/router'; +import { useEffect, useState } from 'react'; +import { + Button, + Form, + FormButtons, + FormInput, + FormRow, + SubmitButton, + TextField, +} from 'react-basics'; +import styles from './FunnelForm.module.css'; +import { getNextInternalQuery } from 'next/dist/server/request-meta'; + +export function FunnelForm({ onSearch }) { + const { formatMessage, labels, getMessage } = useMessages(); + const [dateRange, setDateRange] = useState(null); + const [startDate, setStartDate] = useState(null); + const [endDate, setEndDate] = useState(null); + const [urls, setUrls] = useState(['']); + const [websiteId, setWebsiteId] = useState(''); + + const handleSubmit = async data => { + onSearch(data); + }; + + const handleDateChange = value => { + const { startDate, endDate } = parseDateRange(value); + + setDateRange(value); + setStartDate(startDate); + setEndDate(endDate); + }; + + const handleAddUrl = () => setUrls([...urls, 'meow']); + + const handleRemoveUrl = i => setUrls(urls.splice(i, 1)); + + const handleUrlChange = (value, i) => { + const nextUrls = [...urls]; + + nextUrls[i] = value.target.value; + setUrls(nextUrls); + }; + + return ( + <> +
+ + setWebsiteId(value)} /> + + + + + + + + + + + + + + + {urls.map((a, i) => ( + + + handleUrlChange(value, i)} /> + + ))} + + + Search + +
+ + ); +} + +export default FunnelForm; diff --git a/components/pages/reports/FunnelForm.module.css b/components/pages/reports/FunnelForm.module.css new file mode 100644 index 00000000..9a8d924b --- /dev/null +++ b/components/pages/reports/FunnelForm.module.css @@ -0,0 +1,19 @@ +.filter { + min-width: 200px; +} + +.hiddenInput { + max-height: 100px; +} + +.hiddenInput { + visibility: hidden; + min-height: 0px; + max-height: 0px; +} + +.hidden { + visibility: hidden; + min-height: 0px; + max-height: 0px; +} diff --git a/components/pages/reports/FunnelPage.js b/components/pages/reports/FunnelPage.js new file mode 100644 index 00000000..3cfa63a8 --- /dev/null +++ b/components/pages/reports/FunnelPage.js @@ -0,0 +1,26 @@ +import Page from 'components/layout/Page'; +import FunnelChart from './FunnelChart'; +import FunnelForm from './FunnelForm'; + +export default function FunnelPage() { + function handleOnSearch() { + // do API CALL to api/reports/funnel to get funnelData + // Get DATA + } + + return ( + + funnelPage + {/* */} + website / start/endDate urls: [] + + {/* {!chartLoaded && } + {chartLoaded && ( + <> + {!view && } + {view && } + + )} */} + + ); +} diff --git a/components/pages/settings/profile/DateRangeSetting.js b/components/pages/settings/profile/DateRangeSetting.js index 152aba1d..202bbbe1 100644 --- a/components/pages/settings/profile/DateRangeSetting.js +++ b/components/pages/settings/profile/DateRangeSetting.js @@ -7,13 +7,15 @@ import useMessages from 'hooks/useMessages'; export function DateRangeSetting() { const { formatMessage, labels } = useMessages(); const [dateRange, setDateRange] = useDateRange(); - const { startDate, endDate, value } = dateRange; + const { value } = dateRange; - const handleReset = () => setDateRange(DEFAULT_DATE_RANGE); + const handleReset = () => { + setDateRange(DEFAULT_DATE_RANGE); + }; return ( - + ); diff --git a/db/postgresql/migrations/02_report_schema/migration.sql b/db/postgresql/migrations/02_report_schema/migration.sql index 61f1164d..8b2bf0f5 100644 --- a/db/postgresql/migrations/02_report_schema/migration.sql +++ b/db/postgresql/migrations/02_report_schema/migration.sql @@ -1,18 +1,19 @@ -- CreateTable -CREATE TABLE "report" ( +CREATE TABLE "user_report" ( "report_id" UUID NOT NULL, "user_id" UUID NOT NULL, + "website_id" UUID NOT NULL, "report_name" VARCHAR(200) NOT NULL, "template_name" VARCHAR(200) NOT NULL, "parameters" VARCHAR(6000) NOT NULL, "created_at" TIMESTAMPTZ(6) DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMPTZ(6), - CONSTRAINT "report_pkey" PRIMARY KEY ("report_id") + CONSTRAINT "user_report_pkey" PRIMARY KEY ("report_id") ); -- CreateIndex -CREATE UNIQUE INDEX "report_report_id_key" ON "report"("report_id"); +CREATE UNIQUE INDEX "user_report_report_id_key" ON "user_report"("report_id"); -- CreateIndex -CREATE INDEX "report_user_id_idx" ON "report"("user_id"); +CREATE INDEX "user_report_user_id_idx" ON "user_report"("user_id"); diff --git a/db/postgresql/schema.prisma b/db/postgresql/schema.prisma index 7523ce21..ee5ff4b4 100644 --- a/db/postgresql/schema.prisma +++ b/db/postgresql/schema.prisma @@ -19,7 +19,7 @@ model User { website Website[] teamUser TeamUser[] - ReportTemplate ReportTemplate[] + ReportTemplate UserReport[] @@map("user") } @@ -156,9 +156,10 @@ model TeamWebsite { @@map("team_website") } -model ReportTemplate { +model UserReport { id String @id() @unique() @map("report_id") @db.Uuid userId String @map("user_id") @db.Uuid + websiteId String @map("website_id") @db.Uuid reportName String @map("report_name") @db.VarChar(200) templateName String @map("template_name") @db.VarChar(200) parameters String @map("parameters") @db.VarChar(6000) @@ -168,5 +169,5 @@ model ReportTemplate { user User @relation(fields: [userId], references: [id]) @@index([userId]) - @@map("report") + @@map("user_report") } diff --git a/lib/clickhouse.ts b/lib/clickhouse.ts index 88922c0f..e97be806 100644 --- a/lib/clickhouse.ts +++ b/lib/clickhouse.ts @@ -129,8 +129,8 @@ function getFunnelQuery(urls: string[]): { return urls.reduce( (pv, cv, i) => { pv.columnsQuery += `\n,url_path = {url${i}:String}${ - i > 0 && urls[i - 1] ? ` AND request_url = {url${i - 1}:String}` : '' - },'`; + i > 0 && urls[i - 1] ? ` AND referrer_path = {url${i - 1}:String}` : '' + }`; pv.conditionQuery += `${i > 0 ? ',' : ''} {url${i}:String}`; pv.urlParams[`url${i}`] = cv; @@ -150,7 +150,7 @@ function parseFilters(filters: WebsiteMetricFilter = {}, params: any = {}) { }; } -async function rawQuery(query, params = {}) { +async function rawQuery(query, params = {}): Promise { if (process.env.LOG_QUERY) { log('QUERY:\n', query); log('PARAMETERS:\n', params); @@ -158,7 +158,7 @@ async function rawQuery(query, params = {}) { await connect(); - return clickhouse.query(query, { params }).toPromise(); + return clickhouse.query(query, { params }).toPromise() as Promise; } async function findUnique(data) { diff --git a/lib/prisma.ts b/lib/prisma.ts index ce2238b6..fdd8a58d 100644 --- a/lib/prisma.ts +++ b/lib/prisma.ts @@ -148,7 +148,8 @@ function getFunnelQuery( const levelNumber = i + 1; const start = i > 0 ? ',' : ''; - pv.levelQuery += `\n + if (levelNumber >= 2) { + pv.levelQuery += `\n , level${levelNumber} AS ( select cl.*, l0.created_at level_${levelNumber}_created_at, @@ -156,13 +157,14 @@ function getFunnelQuery( from level${i} cl left join level0 l0 on cl.session_id = l0.session_id - and l0.created_at between cl.level_${levelNumber}_created_at - and ${getAddMinutesQuery(`cl.level_${levelNumber}_created_at`, windowMinutes)} + and l0.created_at between cl.level_${i}_created_at + and ${getAddMinutesQuery(`cl.level_${i}_created_at`, windowMinutes)} and l0.referrer_path = $${i + initParamLength} - and l0.url_path = $${i + initParamLength} + and l0.url_path = $${levelNumber + initParamLength} )`; + } - pv.sumQuery += `\n${start}SUM(CASE WHEN l1_url is not null THEN 1 ELSE 0 END) AS level1`; + pv.sumQuery += `\n${start}SUM(CASE WHEN level_${levelNumber}_url is not null THEN 1 ELSE 0 END) AS level${levelNumber}`; pv.urlFilterQuery += `\n${start}$${levelNumber + initParamLength} `; diff --git a/package.json b/package.json index 0db07f49..7f365db1 100644 --- a/package.json +++ b/package.json @@ -80,6 +80,7 @@ "dotenv": "^10.0.0", "formik": "^2.2.9", "fs-extra": "^10.0.1", + "funnel-graph-js": "^1.3.7", "immer": "^9.0.12", "ipaddr.js": "^2.0.1", "is-ci": "^3.0.1", diff --git a/pages/_app.js b/pages/_app.js index 22458215..bc55355b 100644 --- a/pages/_app.js +++ b/pages/_app.js @@ -9,6 +9,7 @@ import useConfig from 'hooks/useConfig'; import '@fontsource/inter/400.css'; import '@fontsource/inter/700.css'; import 'react-basics/dist/styles.css'; +import 'styles/funnelChart.css'; import 'styles/variables.css'; import 'styles/locale.css'; import 'styles/index.css'; diff --git a/pages/api/reports/funnel.ts b/pages/api/reports/funnel.ts index 080130de..ee450eb6 100644 --- a/pages/api/reports/funnel.ts +++ b/pages/api/reports/funnel.ts @@ -36,14 +36,14 @@ export default async ( const startDate = new Date(+startAt); const endDate = new Date(+endAt); - const data = getPageviewFunnel(websiteId, { + const data = await getPageviewFunnel(websiteId, { startDate, endDate, urls, windowMinutes: window, }); - return ok(res); + return ok(res, data); } return methodNotAllowed(res); diff --git a/pages/reports/funnel.js b/pages/reports/funnel.js new file mode 100644 index 00000000..d4bf7dd2 --- /dev/null +++ b/pages/reports/funnel.js @@ -0,0 +1,22 @@ +import { useRouter } from 'next/router'; +import AppLayout from 'components/layout/AppLayout'; +import FunnelPage from 'components/pages/reports/FunnelPage'; +import useMessages from 'hooks/useMessages'; + +export default function DetailsPage() { + // const { formatMessage, labels } = useMessages(); + // const router = useRouter(); + // const { id } = router.query; + + // if (!id) { + // return null; + // } + + // return {/* */}; + + return ( +
+ +
+ ); +} diff --git a/queries/analytics/pageview/getPageviewFunnel.ts b/queries/analytics/pageview/getPageviewFunnel.ts index f7751630..d80a681c 100644 --- a/queries/analytics/pageview/getPageviewFunnel.ts +++ b/queries/analytics/pageview/getPageviewFunnel.ts @@ -27,7 +27,13 @@ async function relationalQuery( endDate: Date; urls: string[]; }, -) { +): Promise< + { + level: number; + url: string; + count: any; + }[] +> { const { windowMinutes, startDate, endDate, urls } = criteria; const { rawQuery, getFunnelQuery, toUuid } = prisma; const { levelQuery, sumQuery, urlFilterQuery } = getFunnelQuery(urls, windowMinutes); @@ -36,21 +42,24 @@ async function relationalQuery( return rawQuery( `WITH level0 AS ( - select session_id, url_path, created_at - from website_event - where url_path in (${urlFilterQuery}) - website_event.website_id = $1${toUuid()} - and created_at between $2 and $3 - ),level1 AS ( - select session_id, url_path as level1_url, created_at as level1_created_at - from level0 - )${levelQuery} - - SELECT ${sumQuery} - from level3; - `, + select session_id, url_path, referrer_path, created_at + from website_event + where url_path in (${urlFilterQuery}) + and website_id = $1${toUuid()} + and created_at between $2 and $3 + ),level1 AS ( + select session_id, url_path as level_1_url, created_at as level_1_created_at + from level0 + where url_path = $4 + )${levelQuery} + + SELECT ${sumQuery} + from level3; + `, params, - ); + ).then((a: { [key: string]: number }) => { + return urls.map((b, i) => ({ level: i + 1, url: b, count: a[`level${i + 1}`] || 0 })); + }); } async function clickhouseQuery( @@ -61,7 +70,13 @@ async function clickhouseQuery( endDate: Date; urls: string[]; }, -) { +): Promise< + { + level: number; + url: string; + count: any; + }[] +> { const { windowMinutes, startDate, endDate, urls } = criteria; const { rawQuery, getBetweenDates, getFunnelQuery } = clickhouse; const { columnsQuery, conditionQuery, urlParams } = getFunnelQuery(urls); @@ -72,7 +87,7 @@ async function clickhouseQuery( ...urlParams, }; - return rawQuery( + return rawQuery<{ level: number; count: number }[]>( ` SELECT level, count(*) AS count @@ -80,7 +95,7 @@ async function clickhouseQuery( SELECT session_id, windowFunnel({window:UInt32}, 'strict_order') ( - created_at, + created_at ${columnsQuery} ) AS level FROM website_event @@ -93,5 +108,9 @@ async function clickhouseQuery( ORDER BY level ASC; `, params, - ); + ).then(a => { + return a + .filter(b => b.level !== 0) + .map((c, i) => ({ level: c.level, url: urls[i], count: c.count })); + }); } diff --git a/styles/funnelChart.css b/styles/funnelChart.css new file mode 100644 index 00000000..c72d42e7 --- /dev/null +++ b/styles/funnelChart.css @@ -0,0 +1,148 @@ +.svg-funnel-js { + display: inline-block; + position: relative; +} +.svg-funnel-js svg { + display: block; +} +.svg-funnel-js .svg-funnel-js__labels { + position: absolute; + display: flex; + width: 100%; + height: 100%; + top: 0; + left: 0; +} +.svg-funnel-js.svg-funnel-js--vertical .svg-funnel-js__labels { + flex-direction: column; +} + +.svg-funnel-js body { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.svg-funnel-js { + font-family: 'Open Sans', sans-serif; +} +.svg-funnel-js .svg-funnel-js__container { + width: 100%; + height: 100%; +} +.svg-funnel-js .svg-funnel-js__labels { + width: 100%; + box-sizing: border-box; +} +.svg-funnel-js .svg-funnel-js__labels .svg-funnel-js__label { + flex: 1 1 0; + position: relative; +} +.svg-funnel-js .svg-funnel-js__labels .svg-funnel-js__label .label__value { + font-size: 24px; + color: #fff; + line-height: 18px; + margin-bottom: 6px; +} +.svg-funnel-js .svg-funnel-js__labels .svg-funnel-js__label .label__title { + font-size: 12px; + font-weight: bold; + color: #21ffa2; +} +.svg-funnel-js .svg-funnel-js__labels .svg-funnel-js__label .label__percentage { + font-size: 16px; + font-weight: bold; + color: #9896dc; +} +.svg-funnel-js .svg-funnel-js__labels .svg-funnel-js__label .label__segment-percentages { + position: absolute; + top: 50%; + transform: translateY(-50%); + width: 100%; + left: 0; + padding: 8px 24px; + box-sizing: border-box; + background-color: rgba(8, 7, 48, 0.8); + margin-top: 24px; + opacity: 0; + transition: opacity 0.1s ease; + cursor: default; +} +.svg-funnel-js .svg-funnel-js__labels .svg-funnel-js__label .label__segment-percentages ul { + margin: 0; + padding: 0; + list-style-type: none; +} +.svg-funnel-js .svg-funnel-js__labels .svg-funnel-js__label .label__segment-percentages ul li { + font-size: 13px; + line-height: 16px; + color: #fff; + margin: 18px 0; +} +.svg-funnel-js + .svg-funnel-js__labels + .svg-funnel-js__label + .label__segment-percentages + ul + li + .percentage__list-label { + font-weight: bold; + color: #05df9d; +} +.svg-funnel-js .svg-funnel-js__labels .svg-funnel-js__label:hover .label__segment-percentages { + opacity: 1; +} +.svg-funnel-js:not(.svg-funnel-js--vertical) { + padding-top: 64px; + padding-bottom: 16px; +} +.svg-funnel-js:not(.svg-funnel-js--vertical) .svg-funnel-js__label { + padding-left: 24px; +} +.svg-funnel-js:not(.svg-funnel-js--vertical) .svg-funnel-js__label:not(:first-child) { + border-left: 1px solid #9896dc; +} +.svg-funnel-js.svg-funnel-js--vertical { + padding-left: 120px; + padding-right: 16px; +} +.svg-funnel-js.svg-funnel-js--vertical .svg-funnel-js__label { + padding-top: 24px; +} +.svg-funnel-js.svg-funnel-js--vertical .svg-funnel-js__label:not(:first-child) { + border-top: 1px solid #9896dc; +} +.svg-funnel-js.svg-funnel-js--vertical .svg-funnel-js__label .label__segment-percentages { + margin-top: 0; + margin-left: 106px; + width: calc(100% - 106px); +} +.svg-funnel-js.svg-funnel-js--vertical + .svg-funnel-js__label + .label__segment-percentages + .segment-percentage__list { + display: flex; + justify-content: space-around; +} +.svg-funnel-js .svg-funnel-js__subLabels { + display: flex; + justify-content: center; + margin-top: 24px; + position: absolute; + width: 100%; + left: 0; +} +.svg-funnel-js .svg-funnel-js__subLabels .svg-funnel-js__subLabel { + display: flex; + font-size: 12px; + color: #fff; + line-height: 16px; +} +.svg-funnel-js .svg-funnel-js__subLabels .svg-funnel-js__subLabel:not(:first-child) { + margin-left: 16px; +} +.svg-funnel-js .svg-funnel-js__subLabels .svg-funnel-js__subLabel .svg-funnel-js__subLabel--color { + width: 12px; + height: 12px; + border-radius: 50%; + margin: 2px 8px 2px 0; +} diff --git a/yarn.lock b/yarn.lock index 41cca434..1f9ea77a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5500,6 +5500,11 @@ functions-have-names@^1.2.2: resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.2.3.tgz#0404fe4ee2ba2f607f0e0ec3c80bae994133b834" integrity sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ== +funnel-graph-js@^1.3.7: + version "1.4.2" + resolved "https://registry.yarnpkg.com/funnel-graph-js/-/funnel-graph-js-1.4.2.tgz#b82150189e8afa59104d881d5dcf55a28d715342" + integrity sha512-9bnmcBve7RDH9dTF9BLuUpuisKkDka3yrfhs+Z/106ZgJvqIse1RfKQWjW+QdAlTrZqC9oafen7t/KuJKv9ohA== + generic-names@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/generic-names/-/generic-names-4.0.0.tgz#0bd8a2fd23fe8ea16cbd0a279acd69c06933d9a3" From 07cb9f621d930caa6f262e9d1462a72cc49afae5 Mon Sep 17 00:00:00 2001 From: Brian Cao Date: Sun, 14 May 2023 21:38:03 -0700 Subject: [PATCH 3/6] Funnel Table/Chart hooked up. --- components/input/DateFilter.js | 8 +-- components/layout/SettingsLayout.js | 1 + components/messages.js | 6 ++ components/pages/reports/FunnelChart.js | 57 +++++++++---------- components/pages/reports/FunnelForm.js | 56 ++++++++++-------- .../pages/reports/FunnelForm.module.css | 9 +++ components/pages/reports/FunnelPage.js | 34 +++++++---- .../pages/reports/FunnelPage.module.css | 10 ++++ components/pages/reports/FunnelTable.js | 17 ++++++ db/postgresql/schema.prisma | 32 ++++++----- pages/api/reports/funnel.ts | 2 +- pages/settings/reports/funnel.js | 16 ++++++ .../funnel.js => settings/reports/index.js} | 9 ++- 13 files changed, 170 insertions(+), 87 deletions(-) create mode 100644 components/pages/reports/FunnelPage.module.css create mode 100644 components/pages/reports/FunnelTable.js create mode 100644 pages/settings/reports/funnel.js rename pages/{reports/funnel.js => settings/reports/index.js} (76%) diff --git a/components/input/DateFilter.js b/components/input/DateFilter.js index ecdf9039..4d60627d 100644 --- a/components/input/DateFilter.js +++ b/components/input/DateFilter.js @@ -9,7 +9,7 @@ import useApi from 'hooks/useApi'; import useDateRange from 'hooks/useDateRange'; import useMessages from 'hooks/useMessages'; -export function DateFilter({ websiteId, value, className, onChange, isForm, alignment }) { +export function DateFilter({ websiteId, value, className, onChange, alignment }) { const { formatMessage, labels } = useMessages(); const { get } = useApi(); const [dateRange, setDateRange] = useDateRange(websiteId); @@ -23,7 +23,7 @@ export function DateFilter({ websiteId, value, className, onChange, isForm, alig if (data) { const websiteRange = { value, ...getDateRangeValues(new Date(data.createdAt), Date.now()) }; - if (!isForm) { + if (!onChange) { setDateRange(websiteRange); } @@ -32,15 +32,13 @@ export function DateFilter({ websiteId, value, className, onChange, isForm, alig } } } else if (value !== 'all') { - if (!isForm) { + if (!onChange) { setDateRange(value); } if (onChange) { onChange(value); } - - console.log(value); } } diff --git a/components/layout/SettingsLayout.js b/components/layout/SettingsLayout.js index c79f0909..d58154ca 100644 --- a/components/layout/SettingsLayout.js +++ b/components/layout/SettingsLayout.js @@ -15,6 +15,7 @@ export function SettingsLayout({ children }) { const items = [ { key: 'websites', label: formatMessage(labels.websites), url: '/settings/websites' }, { key: 'teams', label: formatMessage(labels.teams), url: '/settings/teams' }, + { key: 'reports', label: 'Reports', url: '/settings/reports/funnel' }, user.isAdmin && { key: 'users', label: formatMessage(labels.users), url: '/settings/users' }, { key: 'profile', label: formatMessage(labels.profile), url: '/settings/profile' }, ].filter(n => n); diff --git a/components/messages.js b/components/messages.js index 245e8591..fe4b833a 100644 --- a/components/messages.js +++ b/components/messages.js @@ -18,7 +18,9 @@ export const labels = defineMessages({ admin: { id: 'label.admin', defaultMessage: 'Administrator' }, confirm: { id: 'label.confirm', defaultMessage: 'Confirm' }, details: { id: 'label.details', defaultMessage: 'Details' }, + website: { id: 'label.website', defaultMessage: 'Website' }, websites: { id: 'label.websites', defaultMessage: 'Websites' }, + reports: { id: 'label.reports', defaultMessage: 'Reports' }, created: { id: 'label.created', defaultMessage: 'Created' }, edit: { id: 'label.edit', defaultMessage: 'Edit' }, name: { id: 'label.name', defaultMessage: 'Name' }, @@ -183,6 +185,10 @@ export const messages = defineMessages({ id: 'message.delete-website-warning', defaultMessage: 'All website data will be deleted.', }, + noResultsFound: { + id: 'messages.no-results-found', + defaultMessage: 'No results were found.', + }, noWebsitesConfigured: { id: 'messages.no-websites-configured', defaultMessage: 'You do not have any websites configured.', diff --git a/components/pages/reports/FunnelChart.js b/components/pages/reports/FunnelChart.js index 44c99092..8739e1da 100644 --- a/components/pages/reports/FunnelChart.js +++ b/components/pages/reports/FunnelChart.js @@ -1,43 +1,40 @@ import FunnelGraph from 'funnel-graph-js/dist/js/funnel-graph'; import { useEffect, useRef } from 'react'; +import EmptyPlaceholder from 'components/common/EmptyPlaceholder'; +import useMessages from 'hooks/useMessages'; -export default function FunnelChart() { +export default function FunnelChart({ data }) { + const { formatMessage, labels, messages } = useMessages(); const funnel = useRef(null); useEffect(() => { - funnel.current.innerHTML = ''; + if (data && data.length > 0) { + funnel.current.innerHTML = ''; - const data = { - labels: ['Cv Sent', '1st Interview', '2nd Interview', '3rd Interview', 'Offer'], - subLabels: ['Cv Sent', '1st Interview', '2nd Interview', '3rd Interview', 'Offer'], - colors: [ - ['#FFB178', '#FF78B1', '#FF3C8E'], - ['#FFB178', '#FF78B1', '#FF3C8E'], - ['#A0BBFF', '#EC77FF'], - ['#A0F9FF', '#7795FF'], - ['#FFB178', '#FF78B1', '#FF3C8E'], - ], - values: [[3500], [3300], [2000], [600], [330]], - }; + const chartData = { + labels: data.map(a => a.url), + colors: ['#147af3', '#e0f2ff'], + values: data.map(a => a.count), + }; - const graph = new FunnelGraph({ - container: '.funnel', - gradientDirection: 'horizontal', - data: data, - displayPercent: true, - direction: 'Vertical', - width: 1000, - height: 350, - subLabelValue: 'values', - }); + const graph = new FunnelGraph({ + container: '.funnel', + gradientDirection: 'horizontal', + data: chartData, + displayPercent: true, + direction: 'Vertical', + width: 1000, + height: 350, + }); - graph.draw(); - }, []); + graph.draw(); + } + }, [data]); return ( -
- FunnelChart -
-
+ <> + {data?.length > 0 &&
} + {data?.length === 0 && } + ); } diff --git a/components/pages/reports/FunnelForm.js b/components/pages/reports/FunnelForm.js index 081105f1..56be6732 100644 --- a/components/pages/reports/FunnelForm.js +++ b/components/pages/reports/FunnelForm.js @@ -1,12 +1,8 @@ -import { useMutation } from '@tanstack/react-query'; import DateFilter from 'components/input/DateFilter'; import WebsiteSelect from 'components/input/WebsiteSelect'; -import useApi from 'hooks/useApi'; import useMessages from 'hooks/useMessages'; -import useUser from 'hooks/useUser'; import { parseDateRange } from 'lib/date'; -import { useRouter } from 'next/router'; -import { useEffect, useState } from 'react'; +import { useState } from 'react'; import { Button, Form, @@ -17,15 +13,15 @@ import { TextField, } from 'react-basics'; import styles from './FunnelForm.module.css'; -import { getNextInternalQuery } from 'next/dist/server/request-meta'; export function FunnelForm({ onSearch }) { - const { formatMessage, labels, getMessage } = useMessages(); - const [dateRange, setDateRange] = useState(null); - const [startDate, setStartDate] = useState(null); - const [endDate, setEndDate] = useState(null); + const { formatMessage, labels } = useMessages(); + const [dateRange, setDateRange] = useState(''); + const [startAt, setStartAt] = useState(); + const [endAt, setEndAt] = useState(); const [urls, setUrls] = useState(['']); const [websiteId, setWebsiteId] = useState(''); + const [window, setWindow] = useState(60); const handleSubmit = async data => { onSearch(data); @@ -35,14 +31,16 @@ export function FunnelForm({ onSearch }) { const { startDate, endDate } = parseDateRange(value); setDateRange(value); - setStartDate(startDate); - setEndDate(endDate); + setStartAt(startDate.getTime()); + setEndAt(endDate.getTime()); }; - const handleAddUrl = () => setUrls([...urls, 'meow']); + const handleAddUrl = () => setUrls([...urls, '']); const handleRemoveUrl = i => setUrls(urls.splice(i, 1)); + const handleWindowChange = value => setWindow(value.target.value); + const handleUrlChange = (value, i) => { const nextUrls = [...urls]; @@ -55,13 +53,14 @@ export function FunnelForm({ onSearch }) {
- + setWebsiteId(value)} /> @@ -73,28 +72,39 @@ export function FunnelForm({ onSearch }) { value={dateRange} alignment="start" onChange={handleDateChange} + isF /> - + - - + + + + + + + {urls.map((a, i) => ( - - + handleUrlChange(value, i)} /> + ))} - Search + + Search + diff --git a/components/pages/reports/FunnelForm.module.css b/components/pages/reports/FunnelForm.module.css index 9a8d924b..2706a99a 100644 --- a/components/pages/reports/FunnelForm.module.css +++ b/components/pages/reports/FunnelForm.module.css @@ -17,3 +17,12 @@ min-height: 0px; max-height: 0px; } + +.urlFormRow { + flex-direction: row; + gap: 0em; +} + +.urlFormRow label { + min-width: 80px; +} diff --git a/components/pages/reports/FunnelPage.js b/components/pages/reports/FunnelPage.js index 3cfa63a8..c715d857 100644 --- a/components/pages/reports/FunnelPage.js +++ b/components/pages/reports/FunnelPage.js @@ -1,26 +1,38 @@ +import { useMutation } from '@tanstack/react-query'; import Page from 'components/layout/Page'; +import PageHeader from 'components/layout/PageHeader'; +import useApi from 'hooks/useApi'; +import { useState } from 'react'; import FunnelChart from './FunnelChart'; +import FunnelTable from './FunnelTable'; import FunnelForm from './FunnelForm'; +import styles from './FunnelPage.module.css'; export default function FunnelPage() { - function handleOnSearch() { + const { post } = useApi(); + const { mutate, error, isLoading } = useMutation(data => post('/reports/funnel', data)); + const [data, setData] = useState(); + + function handleOnSearch(data) { // do API CALL to api/reports/funnel to get funnelData // Get DATA + mutate(data, { + onSuccess: async data => { + setData(data); + }, + }); } return ( - funnelPage + + + {/* */} - website / start/endDate urls: [] - - {/* {!chartLoaded && } - {chartLoaded && ( - <> - {!view && } - {view && } - - )} */} +
+

Filters

+ +
); } diff --git a/components/pages/reports/FunnelPage.module.css b/components/pages/reports/FunnelPage.module.css new file mode 100644 index 00000000..aed66b74 --- /dev/null +++ b/components/pages/reports/FunnelPage.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/FunnelTable.js b/components/pages/reports/FunnelTable.js new file mode 100644 index 00000000..fa40fd13 --- /dev/null +++ b/components/pages/reports/FunnelTable.js @@ -0,0 +1,17 @@ +import DataTable from 'components/metrics/DataTable'; +import useMessages from 'hooks/useMessages'; +import { useState } from 'react'; + +export function DevicesTable({ ...props }) { + const { formatMessage, labels } = useMessages(); + const { data } = props; + + const tableData = + data?.map(a => ({ x: a.url, y: a.count, z: (a.count / data[0].count) * 100 })) || []; + + console.log(tableData); + + return ; +} + +export default DevicesTable; diff --git a/db/postgresql/schema.prisma b/db/postgresql/schema.prisma index ee5ff4b4..318d455d 100644 --- a/db/postgresql/schema.prisma +++ b/db/postgresql/schema.prisma @@ -17,9 +17,9 @@ model User { updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6) deletedAt DateTime? @map("deleted_at") @db.Timestamptz(6) - website Website[] - teamUser TeamUser[] - ReportTemplate UserReport[] + website Website[] + teamUser TeamUser[] + Report Report[] @@map("user") } @@ -60,6 +60,7 @@ model Website { user User? @relation(fields: [userId], references: [id]) teamWebsite TeamWebsite[] eventData EventData[] + Report Report[] @@index([userId]) @@index([createdAt]) @@ -156,18 +157,21 @@ model TeamWebsite { @@map("team_website") } -model UserReport { - id String @id() @unique() @map("report_id") @db.Uuid - userId String @map("user_id") @db.Uuid - websiteId String @map("website_id") @db.Uuid - reportName String @map("report_name") @db.VarChar(200) - templateName String @map("template_name") @db.VarChar(200) - parameters String @map("parameters") @db.VarChar(6000) - createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6) - updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6) +model Report { + id String @id() @unique() @map("report_id") @db.Uuid + userId String @map("user_id") @db.Uuid + websiteId String @map("website_id") @db.Uuid + type String @map("type") @db.VarChar(200) + name String @map("name") @db.VarChar(200) + description String @map("description") @db.VarChar(500) + parameters String @map("parameters") @db.VarChar(6000) + createdAt DateTime? @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime? @updatedAt @map("updated_at") @db.Timestamptz(6) - user User @relation(fields: [userId], references: [id]) + user User @relation(fields: [userId], references: [id]) + website Website @relation(fields: [websiteId], references: [id]) @@index([userId]) - @@map("user_report") + @@index([websiteId]) + @@map("report") } diff --git a/pages/api/reports/funnel.ts b/pages/api/reports/funnel.ts index ee450eb6..6e3eb602 100644 --- a/pages/api/reports/funnel.ts +++ b/pages/api/reports/funnel.ts @@ -40,7 +40,7 @@ export default async ( startDate, endDate, urls, - windowMinutes: window, + windowMinutes: +window, }); return ok(res, data); diff --git a/pages/settings/reports/funnel.js b/pages/settings/reports/funnel.js new file mode 100644 index 00000000..d8d7a5b8 --- /dev/null +++ b/pages/settings/reports/funnel.js @@ -0,0 +1,16 @@ +import AppLayout from 'components/layout/AppLayout'; +import SettingsLayout from 'components/layout/SettingsLayout'; +import FunnelPage from 'components/pages/reports/FunnelPage'; +import useMessages from 'hooks/useMessages'; + +export default function DetailsPage() { + const { formatMessage, labels } = useMessages(); + + return ( + + + + + + ); +} diff --git a/pages/reports/funnel.js b/pages/settings/reports/index.js similarity index 76% rename from pages/reports/funnel.js rename to pages/settings/reports/index.js index d4bf7dd2..ce0a3726 100644 --- a/pages/reports/funnel.js +++ b/pages/settings/reports/index.js @@ -2,6 +2,7 @@ import { useRouter } from 'next/router'; import AppLayout from 'components/layout/AppLayout'; import FunnelPage from 'components/pages/reports/FunnelPage'; import useMessages from 'hooks/useMessages'; +import SettingsLayout from 'components/layout/SettingsLayout'; export default function DetailsPage() { // const { formatMessage, labels } = useMessages(); @@ -15,8 +16,10 @@ export default function DetailsPage() { // return {/* */}; return ( -
- -
+ + + + + ); } From b5f84159d2925bb05585f62200c7593aa91f8194 Mon Sep 17 00:00:00 2001 From: Brian Cao Date: Mon, 15 May 2023 14:03:42 -0700 Subject: [PATCH 4/6] funnel checkpoint --- components/pages/reports/FunnelChart.js | 4 +++- components/pages/reports/FunnelChart.module.css | 7 +++++++ components/pages/reports/FunnelForm.js | 2 +- components/pages/reports/FunnelPage.js | 6 +++--- components/pages/reports/FunnelTable.js | 2 -- components/pages/reports/ReportDropdown.js | 0 components/pages/reports/ReportForm.js | 0 queries/analytics/pageview/getPageviewFunnel.ts | 12 +++++++----- 8 files changed, 21 insertions(+), 12 deletions(-) create mode 100644 components/pages/reports/FunnelChart.module.css create mode 100644 components/pages/reports/ReportDropdown.js create mode 100644 components/pages/reports/ReportForm.js diff --git a/components/pages/reports/FunnelChart.js b/components/pages/reports/FunnelChart.js index 8739e1da..ec03acab 100644 --- a/components/pages/reports/FunnelChart.js +++ b/components/pages/reports/FunnelChart.js @@ -2,6 +2,8 @@ import FunnelGraph from 'funnel-graph-js/dist/js/funnel-graph'; import { useEffect, useRef } from 'react'; import EmptyPlaceholder from 'components/common/EmptyPlaceholder'; import useMessages from 'hooks/useMessages'; +import styles from './FunnelChart.module.css'; +import classNames from 'classnames'; export default function FunnelChart({ data }) { const { formatMessage, labels, messages } = useMessages(); @@ -33,7 +35,7 @@ export default function FunnelChart({ data }) { return ( <> - {data?.length > 0 &&
} + {data?.length > 0 &&
} {data?.length === 0 && } ); diff --git a/components/pages/reports/FunnelChart.module.css b/components/pages/reports/FunnelChart.module.css new file mode 100644 index 00000000..1d7eb37e --- /dev/null +++ b/components/pages/reports/FunnelChart.module.css @@ -0,0 +1,7 @@ +.funnel div { + color: var(--font-color100) !important; +} + +.funnel svg { + max-width: 100%; +} diff --git a/components/pages/reports/FunnelForm.js b/components/pages/reports/FunnelForm.js index 56be6732..5f9758f0 100644 --- a/components/pages/reports/FunnelForm.js +++ b/components/pages/reports/FunnelForm.js @@ -19,7 +19,7 @@ export function FunnelForm({ onSearch }) { const [dateRange, setDateRange] = useState(''); const [startAt, setStartAt] = useState(); const [endAt, setEndAt] = useState(); - const [urls, setUrls] = useState(['']); + const [urls, setUrls] = useState(['/', '/docs/getting-started', '/docs/intall']); const [websiteId, setWebsiteId] = useState(''); const [window, setWindow] = useState(60); diff --git a/components/pages/reports/FunnelPage.js b/components/pages/reports/FunnelPage.js index c715d857..b69319b1 100644 --- a/components/pages/reports/FunnelPage.js +++ b/components/pages/reports/FunnelPage.js @@ -12,10 +12,11 @@ export default function FunnelPage() { const { post } = useApi(); const { mutate, error, isLoading } = useMutation(data => post('/reports/funnel', data)); const [data, setData] = useState(); + const [formData, setFormData] = useState(); function handleOnSearch(data) { - // do API CALL to api/reports/funnel to get funnelData - // Get DATA + setFormData(data); + mutate(data, { onSuccess: async data => { setData(data); @@ -28,7 +29,6 @@ export default function FunnelPage() { - {/* */}

Filters

diff --git a/components/pages/reports/FunnelTable.js b/components/pages/reports/FunnelTable.js index fa40fd13..036e20c3 100644 --- a/components/pages/reports/FunnelTable.js +++ b/components/pages/reports/FunnelTable.js @@ -9,8 +9,6 @@ export function DevicesTable({ ...props }) { const tableData = data?.map(a => ({ x: a.url, y: a.count, z: (a.count / data[0].count) * 100 })) || []; - console.log(tableData); - return ; } diff --git a/components/pages/reports/ReportDropdown.js b/components/pages/reports/ReportDropdown.js new file mode 100644 index 00000000..e69de29b diff --git a/components/pages/reports/ReportForm.js b/components/pages/reports/ReportForm.js new file mode 100644 index 00000000..e69de29b diff --git a/queries/analytics/pageview/getPageviewFunnel.ts b/queries/analytics/pageview/getPageviewFunnel.ts index d80a681c..591310cf 100644 --- a/queries/analytics/pageview/getPageviewFunnel.ts +++ b/queries/analytics/pageview/getPageviewFunnel.ts @@ -74,7 +74,7 @@ async function clickhouseQuery( { level: number; url: string; - count: any; + count: number; }[] > { const { windowMinutes, startDate, endDate, urls } = criteria; @@ -108,9 +108,11 @@ async function clickhouseQuery( ORDER BY level ASC; `, params, - ).then(a => { - return a - .filter(b => b.level !== 0) - .map((c, i) => ({ level: c.level, url: urls[i], count: c.count })); + ).then(results => { + return urls.map((a, i) => ({ + level: i + 1, + url: a, + count: results[i + 1]?.count || 0, + })); }); } From de509e7cccfbe988a627f498ea3a400a893e563e Mon Sep 17 00:00:00 2001 From: Brian Cao Date: Thu, 18 May 2023 11:17:35 -0700 Subject: [PATCH 5/6] checkpoint --- components/layout/NavBar.js | 1 + components/layout/ReportsLayout.js | 23 +++ components/layout/ReportsLayout.module.css | 23 +++ components/layout/SettingsLayout.js | 1 - components/pages/reports/FunnelChart.js | 42 ---- .../pages/reports/FunnelChart.module.css | 7 - components/pages/reports/FunnelDetails.js | 0 components/pages/reports/ReportDropdown.js | 0 components/pages/reports/ReportForm.js | 28 +++ .../pages/reports/ReportForm.module.css | 23 +++ .../pages/reports/funnel/FunnelChart.js | 186 ++++++++++++++++++ .../reports/funnel/FunnelChart.module.css | 23 +++ .../pages/reports/{ => funnel}/FunnelForm.js | 8 +- .../{ => funnel}/FunnelForm.module.css | 5 - .../pages/reports/{ => funnel}/FunnelPage.js | 20 +- .../{ => funnel}/FunnelPage.module.css | 0 .../pages/reports/{ => funnel}/FunnelTable.js | 3 +- package.json | 1 - pages/_app.js | 1 - pages/{settings => }/reports/funnel.js | 9 +- pages/{settings => }/reports/index.js | 2 +- .../analytics/pageview/getPageviewFunnel.ts | 17 +- styles/funnelChart.css | 148 -------------- 23 files changed, 335 insertions(+), 236 deletions(-) create mode 100644 components/layout/ReportsLayout.js create mode 100644 components/layout/ReportsLayout.module.css delete mode 100644 components/pages/reports/FunnelChart.js delete mode 100644 components/pages/reports/FunnelChart.module.css delete mode 100644 components/pages/reports/FunnelDetails.js delete mode 100644 components/pages/reports/ReportDropdown.js create mode 100644 components/pages/reports/ReportForm.module.css create mode 100644 components/pages/reports/funnel/FunnelChart.js create mode 100644 components/pages/reports/funnel/FunnelChart.module.css rename components/pages/reports/{ => funnel}/FunnelForm.js (96%) rename components/pages/reports/{ => funnel}/FunnelForm.module.css (83%) rename components/pages/reports/{ => funnel}/FunnelPage.js (67%) rename components/pages/reports/{ => funnel}/FunnelPage.module.css (100%) rename components/pages/reports/{ => funnel}/FunnelTable.js (73%) rename pages/{settings => }/reports/funnel.js (54%) rename pages/{settings => }/reports/index.js (90%) delete mode 100644 styles/funnelChart.css diff --git a/components/layout/NavBar.js b/components/layout/NavBar.js index 5a6c877e..ba06d172 100644 --- a/components/layout/NavBar.js +++ b/components/layout/NavBar.js @@ -19,6 +19,7 @@ export function NavBar() { const links = [ { label: formatMessage(labels.dashboard), url: '/dashboard' }, { label: formatMessage(labels.realtime), url: '/realtime' }, + { label: formatMessage(labels.reports), url: '/reports/funnel' }, !cloudMode && { label: formatMessage(labels.settings), url: '/settings' }, ].filter(n => n); diff --git a/components/layout/ReportsLayout.js b/components/layout/ReportsLayout.js new file mode 100644 index 00000000..fd63a67e --- /dev/null +++ b/components/layout/ReportsLayout.js @@ -0,0 +1,23 @@ +import { Column, Row } from 'react-basics'; +import styles from './ReportsLayout.module.css'; + +export function SettingsLayout({ children, filter, header }) { + return ( + <> + {header} + + {filter && ( + +

Filters

+ {filter} +
+ )} + + {children} + +
+ + ); +} + +export default SettingsLayout; diff --git a/components/layout/ReportsLayout.module.css b/components/layout/ReportsLayout.module.css new file mode 100644 index 00000000..6922665f --- /dev/null +++ b/components/layout/ReportsLayout.module.css @@ -0,0 +1,23 @@ +.filter { + margin-top: 30px; + min-width: 200px; + max-width: 100vw; + padding: 10px; + background: var(--base50); + border-radius: 5px; + border: 1px solid var(--border-color); +} + +.filter h2 { + padding-bottom: 20px; +} + +.content { + min-height: 50vh; +} + +@media only screen and (max-width: 768px) { + .menu { + display: none; + } +} diff --git a/components/layout/SettingsLayout.js b/components/layout/SettingsLayout.js index d58154ca..c79f0909 100644 --- a/components/layout/SettingsLayout.js +++ b/components/layout/SettingsLayout.js @@ -15,7 +15,6 @@ export function SettingsLayout({ children }) { const items = [ { key: 'websites', label: formatMessage(labels.websites), url: '/settings/websites' }, { key: 'teams', label: formatMessage(labels.teams), url: '/settings/teams' }, - { key: 'reports', label: 'Reports', url: '/settings/reports/funnel' }, user.isAdmin && { key: 'users', label: formatMessage(labels.users), url: '/settings/users' }, { key: 'profile', label: formatMessage(labels.profile), url: '/settings/profile' }, ].filter(n => n); diff --git a/components/pages/reports/FunnelChart.js b/components/pages/reports/FunnelChart.js deleted file mode 100644 index ec03acab..00000000 --- a/components/pages/reports/FunnelChart.js +++ /dev/null @@ -1,42 +0,0 @@ -import FunnelGraph from 'funnel-graph-js/dist/js/funnel-graph'; -import { useEffect, useRef } from 'react'; -import EmptyPlaceholder from 'components/common/EmptyPlaceholder'; -import useMessages from 'hooks/useMessages'; -import styles from './FunnelChart.module.css'; -import classNames from 'classnames'; - -export default function FunnelChart({ data }) { - const { formatMessage, labels, messages } = useMessages(); - const funnel = useRef(null); - - useEffect(() => { - if (data && data.length > 0) { - funnel.current.innerHTML = ''; - - const chartData = { - labels: data.map(a => a.url), - colors: ['#147af3', '#e0f2ff'], - values: data.map(a => a.count), - }; - - const graph = new FunnelGraph({ - container: '.funnel', - gradientDirection: 'horizontal', - data: chartData, - displayPercent: true, - direction: 'Vertical', - width: 1000, - height: 350, - }); - - graph.draw(); - } - }, [data]); - - return ( - <> - {data?.length > 0 &&
} - {data?.length === 0 && } - - ); -} diff --git a/components/pages/reports/FunnelChart.module.css b/components/pages/reports/FunnelChart.module.css deleted file mode 100644 index 1d7eb37e..00000000 --- a/components/pages/reports/FunnelChart.module.css +++ /dev/null @@ -1,7 +0,0 @@ -.funnel div { - color: var(--font-color100) !important; -} - -.funnel svg { - max-width: 100%; -} diff --git a/components/pages/reports/FunnelDetails.js b/components/pages/reports/FunnelDetails.js deleted file mode 100644 index e69de29b..00000000 diff --git a/components/pages/reports/ReportDropdown.js b/components/pages/reports/ReportDropdown.js deleted file mode 100644 index e69de29b..00000000 diff --git a/components/pages/reports/ReportForm.js b/components/pages/reports/ReportForm.js index e69de29b..b32a8531 100644 --- a/components/pages/reports/ReportForm.js +++ b/components/pages/reports/ReportForm.js @@ -0,0 +1,28 @@ +import useMessages from 'hooks/useMessages'; +import { Form, FormButtons, FormInput, FormRow, SubmitButton, TextField } from 'react-basics'; + +export function FunnelForm({ onSearch }) { + const { formatMessage, labels } = useMessages(); + + const handleSubmit = () => {}; + + return ( + <> +
+ + + + + + + + + Save + + +
+ + ); +} + +export default FunnelForm; diff --git a/components/pages/reports/ReportForm.module.css b/components/pages/reports/ReportForm.module.css new file mode 100644 index 00000000..2a07d552 --- /dev/null +++ b/components/pages/reports/ReportForm.module.css @@ -0,0 +1,23 @@ +.filter { + min-width: 200px; +} + +.hiddenInput { + max-height: 100px; +} + +.hiddenInput { + visibility: hidden; + min-height: 0px; + max-height: 0px; +} + +.hidden { + visibility: hidden; + min-height: 0px; + max-height: 0px; +} + +.urlFormRow label { + min-width: 80px; +} diff --git a/components/pages/reports/funnel/FunnelChart.js b/components/pages/reports/funnel/FunnelChart.js new file mode 100644 index 00000000..5a0b5699 --- /dev/null +++ b/components/pages/reports/funnel/FunnelChart.js @@ -0,0 +1,186 @@ +import Chart from 'chart.js/auto'; +import classNames from 'classnames'; +import { colord } from 'colord'; +import HoverTooltip from 'components/common/HoverTooltip'; +import Legend from 'components/metrics/Legend'; +import useLocale from 'hooks/useLocale'; +import useMessages from 'hooks/useMessages'; +import useTheme from 'hooks/useTheme'; +import { DEFAULT_ANIMATION_DURATION, THEME_COLORS } from 'lib/constants'; +import { dateFormat } from 'lib/date'; +import { formatLongNumber } from 'lib/format'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Loading, StatusLight } from 'react-basics'; +import styles from './FunnelChart.module.css'; + +export function FunnelChart({ + data, + animationDuration = DEFAULT_ANIMATION_DURATION, + stacked = false, + loading = false, + onCreate = () => {}, + onUpdate = () => {}, + className, +}) { + const { formatMessage, labels } = useMessages(); + const canvas = useRef(); + const chart = useRef(null); + const [tooltip, setTooltip] = useState(null); + const { locale } = useLocale(); + const [theme] = useTheme(); + + const datasets = useMemo(() => { + const primaryColor = colord(THEME_COLORS[theme].primary); + return [ + { + label: formatMessage(labels.uniqueVisitors), + data: data, + borderWidth: 1, + hoverBackgroundColor: primaryColor.alpha(0.9).toRgbString(), + backgroundColor: primaryColor.alpha(0.6).toRgbString(), + borderColor: primaryColor.alpha(0.9).toRgbString(), + hoverBorderColor: primaryColor.toRgbString(), + }, + ]; + }, [data]); + + const colors = useMemo( + () => ({ + text: THEME_COLORS[theme].gray700, + line: THEME_COLORS[theme].gray200, + }), + [theme], + ); + + const renderYLabel = label => { + return +label > 1000 ? formatLongNumber(label) : label; + }; + + const renderTooltip = useCallback(model => { + const { opacity, labelColors, dataPoints } = model.tooltip; + + if (!dataPoints?.length || !opacity) { + setTooltip(null); + return; + } + + setTooltip( +
+
+ +
+
{dataPoints[0].raw.x}
+
{formatLongNumber(dataPoints[0].raw.y)}
+
+
+
+
, + ); + }, []); + + const getOptions = useCallback(() => { + return { + responsive: true, + maintainAspectRatio: false, + animation: { + duration: animationDuration, + resize: { + duration: 0, + }, + active: { + duration: 0, + }, + }, + plugins: { + legend: { + display: false, + }, + tooltip: { + enabled: false, + external: renderTooltip, + }, + }, + scales: { + x: { + grid: { + display: false, + }, + border: { + color: colors.line, + }, + ticks: { + color: colors.text, + autoSkip: false, + maxRotation: 0, + }, + }, + y: { + type: 'linear', + min: 0, + beginAtZero: true, + stacked, + grid: { + color: colors.line, + }, + border: { + color: colors.line, + }, + ticks: { + color: colors.text, + callback: renderYLabel, + }, + }, + }, + }; + }, [animationDuration, renderTooltip, stacked, colors, locale]); + + const createChart = () => { + Chart.defaults.font.family = 'Inter'; + + const options = getOptions(); + + chart.current = new Chart(canvas.current, { + type: 'bar', + data: { datasets }, + options, + }); + + onCreate(chart.current); + }; + + const updateChart = () => { + setTooltip(null); + + chart.current.data.datasets[0].data = datasets[0].data; + chart.current.data.datasets[0].label = datasets[0].label; + + chart.current.options = getOptions(); + + onUpdate(chart.current); + + chart.current.update(); + }; + + useEffect(() => { + if (datasets) { + if (!chart.current) { + createChart(); + } else { + updateChart(); + } + } + }, [datasets, theme, animationDuration, locale]); + + return ( + <> +
+ {loading && } + +
+ + {tooltip && } + + ); +} + +export default FunnelChart; diff --git a/components/pages/reports/funnel/FunnelChart.module.css b/components/pages/reports/funnel/FunnelChart.module.css new file mode 100644 index 00000000..f071a29e --- /dev/null +++ b/components/pages/reports/funnel/FunnelChart.module.css @@ -0,0 +1,23 @@ +.chart { + position: relative; + height: 400px; + overflow: hidden; +} + +.tooltip { + display: flex; + flex-direction: column; + gap: 10px; +} + +.tooltip .value { + display: flex; + flex-direction: column; + text-transform: lowercase; +} + +@media only screen and (max-width: 992px) { + .chart { + /*height: 200px;*/ + } +} diff --git a/components/pages/reports/FunnelForm.js b/components/pages/reports/funnel/FunnelForm.js similarity index 96% rename from components/pages/reports/FunnelForm.js rename to components/pages/reports/funnel/FunnelForm.js index 5f9758f0..30edcc56 100644 --- a/components/pages/reports/FunnelForm.js +++ b/components/pages/reports/funnel/FunnelForm.js @@ -37,7 +37,11 @@ export function FunnelForm({ onSearch }) { const handleAddUrl = () => setUrls([...urls, '']); - const handleRemoveUrl = i => setUrls(urls.splice(i, 1)); + const handleRemoveUrl = i => { + const nextUrls = [...urls]; + nextUrls.splice(i, 1); + setUrls(nextUrls); + }; const handleWindowChange = value => setWindow(value.target.value); @@ -103,7 +107,7 @@ export function FunnelForm({ onSearch }) { - Search + Query diff --git a/components/pages/reports/FunnelForm.module.css b/components/pages/reports/funnel/FunnelForm.module.css similarity index 83% rename from components/pages/reports/FunnelForm.module.css rename to components/pages/reports/funnel/FunnelForm.module.css index 2706a99a..2a07d552 100644 --- a/components/pages/reports/FunnelForm.module.css +++ b/components/pages/reports/funnel/FunnelForm.module.css @@ -18,11 +18,6 @@ max-height: 0px; } -.urlFormRow { - flex-direction: row; - gap: 0em; -} - .urlFormRow label { min-width: 80px; } diff --git a/components/pages/reports/FunnelPage.js b/components/pages/reports/funnel/FunnelPage.js similarity index 67% rename from components/pages/reports/FunnelPage.js rename to components/pages/reports/funnel/FunnelPage.js index b69319b1..3bfbb9e3 100644 --- a/components/pages/reports/FunnelPage.js +++ b/components/pages/reports/funnel/FunnelPage.js @@ -1,17 +1,19 @@ import { useMutation } from '@tanstack/react-query'; import Page from 'components/layout/Page'; import PageHeader from 'components/layout/PageHeader'; +import ReportsLayout from 'components/layout/ReportsLayout'; import useApi from 'hooks/useApi'; import { useState } from 'react'; import FunnelChart from './FunnelChart'; import FunnelTable from './FunnelTable'; import FunnelForm from './FunnelForm'; + import styles from './FunnelPage.module.css'; export default function FunnelPage() { const { post } = useApi(); const { mutate, error, isLoading } = useMutation(data => post('/reports/funnel', data)); - const [data, setData] = useState(); + const [data, setData] = useState([{}]); const [formData, setFormData] = useState(); function handleOnSearch(data) { @@ -25,14 +27,12 @@ export default function FunnelPage() { } return ( - - - - -
-

Filters

- -
-
+ } header={'test'}> + + + + + + ); } diff --git a/components/pages/reports/FunnelPage.module.css b/components/pages/reports/funnel/FunnelPage.module.css similarity index 100% rename from components/pages/reports/FunnelPage.module.css rename to components/pages/reports/funnel/FunnelPage.module.css diff --git a/components/pages/reports/FunnelTable.js b/components/pages/reports/funnel/FunnelTable.js similarity index 73% rename from components/pages/reports/FunnelTable.js rename to components/pages/reports/funnel/FunnelTable.js index 036e20c3..2bbabc81 100644 --- a/components/pages/reports/FunnelTable.js +++ b/components/pages/reports/funnel/FunnelTable.js @@ -1,13 +1,12 @@ import DataTable from 'components/metrics/DataTable'; import useMessages from 'hooks/useMessages'; -import { useState } from 'react'; export function DevicesTable({ ...props }) { const { formatMessage, labels } = useMessages(); const { data } = props; const tableData = - data?.map(a => ({ x: a.url, y: a.count, z: (a.count / data[0].count) * 100 })) || []; + data?.map(a => ({ x: a.x, y: a.y, z: Math.floor(a.y / data[0].y) * 100 })) || []; return ; } diff --git a/package.json b/package.json index 7f365db1..0db07f49 100644 --- a/package.json +++ b/package.json @@ -80,7 +80,6 @@ "dotenv": "^10.0.0", "formik": "^2.2.9", "fs-extra": "^10.0.1", - "funnel-graph-js": "^1.3.7", "immer": "^9.0.12", "ipaddr.js": "^2.0.1", "is-ci": "^3.0.1", diff --git a/pages/_app.js b/pages/_app.js index bc55355b..22458215 100644 --- a/pages/_app.js +++ b/pages/_app.js @@ -9,7 +9,6 @@ import useConfig from 'hooks/useConfig'; import '@fontsource/inter/400.css'; import '@fontsource/inter/700.css'; import 'react-basics/dist/styles.css'; -import 'styles/funnelChart.css'; import 'styles/variables.css'; import 'styles/locale.css'; import 'styles/index.css'; diff --git a/pages/settings/reports/funnel.js b/pages/reports/funnel.js similarity index 54% rename from pages/settings/reports/funnel.js rename to pages/reports/funnel.js index d8d7a5b8..3ba11306 100644 --- a/pages/settings/reports/funnel.js +++ b/pages/reports/funnel.js @@ -1,16 +1,13 @@ import AppLayout from 'components/layout/AppLayout'; -import SettingsLayout from 'components/layout/SettingsLayout'; -import FunnelPage from 'components/pages/reports/FunnelPage'; +import FunnelPage from 'components/pages/reports/funnel/FunnelPage'; import useMessages from 'hooks/useMessages'; -export default function DetailsPage() { +export default function Funnel() { const { formatMessage, labels } = useMessages(); return ( - - - + ); } diff --git a/pages/settings/reports/index.js b/pages/reports/index.js similarity index 90% rename from pages/settings/reports/index.js rename to pages/reports/index.js index ce0a3726..cdd6ae38 100644 --- a/pages/settings/reports/index.js +++ b/pages/reports/index.js @@ -1,6 +1,6 @@ import { useRouter } from 'next/router'; import AppLayout from 'components/layout/AppLayout'; -import FunnelPage from 'components/pages/reports/FunnelPage'; +import FunnelPage from 'components/pages/reports/funnel/FunnelPage'; import useMessages from 'hooks/useMessages'; import SettingsLayout from 'components/layout/SettingsLayout'; diff --git a/queries/analytics/pageview/getPageviewFunnel.ts b/queries/analytics/pageview/getPageviewFunnel.ts index 591310cf..ef62e526 100644 --- a/queries/analytics/pageview/getPageviewFunnel.ts +++ b/queries/analytics/pageview/getPageviewFunnel.ts @@ -29,9 +29,8 @@ async function relationalQuery( }, ): Promise< { - level: number; - url: string; - count: any; + x: string; + y: number; }[] > { const { windowMinutes, startDate, endDate, urls } = criteria; @@ -58,7 +57,7 @@ async function relationalQuery( `, params, ).then((a: { [key: string]: number }) => { - return urls.map((b, i) => ({ level: i + 1, url: b, count: a[`level${i + 1}`] || 0 })); + return urls.map((b, i) => ({ x: b, y: a[`level${i + 1}`] || 0 })); }); } @@ -72,9 +71,8 @@ async function clickhouseQuery( }, ): Promise< { - level: number; - url: string; - count: number; + x: string; + y: number; }[] > { const { windowMinutes, startDate, endDate, urls } = criteria; @@ -110,9 +108,8 @@ async function clickhouseQuery( params, ).then(results => { return urls.map((a, i) => ({ - level: i + 1, - url: a, - count: results[i + 1]?.count || 0, + x: a, + y: results[i + 1]?.count || 0, })); }); } diff --git a/styles/funnelChart.css b/styles/funnelChart.css deleted file mode 100644 index c72d42e7..00000000 --- a/styles/funnelChart.css +++ /dev/null @@ -1,148 +0,0 @@ -.svg-funnel-js { - display: inline-block; - position: relative; -} -.svg-funnel-js svg { - display: block; -} -.svg-funnel-js .svg-funnel-js__labels { - position: absolute; - display: flex; - width: 100%; - height: 100%; - top: 0; - left: 0; -} -.svg-funnel-js.svg-funnel-js--vertical .svg-funnel-js__labels { - flex-direction: column; -} - -.svg-funnel-js body { - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -.svg-funnel-js { - font-family: 'Open Sans', sans-serif; -} -.svg-funnel-js .svg-funnel-js__container { - width: 100%; - height: 100%; -} -.svg-funnel-js .svg-funnel-js__labels { - width: 100%; - box-sizing: border-box; -} -.svg-funnel-js .svg-funnel-js__labels .svg-funnel-js__label { - flex: 1 1 0; - position: relative; -} -.svg-funnel-js .svg-funnel-js__labels .svg-funnel-js__label .label__value { - font-size: 24px; - color: #fff; - line-height: 18px; - margin-bottom: 6px; -} -.svg-funnel-js .svg-funnel-js__labels .svg-funnel-js__label .label__title { - font-size: 12px; - font-weight: bold; - color: #21ffa2; -} -.svg-funnel-js .svg-funnel-js__labels .svg-funnel-js__label .label__percentage { - font-size: 16px; - font-weight: bold; - color: #9896dc; -} -.svg-funnel-js .svg-funnel-js__labels .svg-funnel-js__label .label__segment-percentages { - position: absolute; - top: 50%; - transform: translateY(-50%); - width: 100%; - left: 0; - padding: 8px 24px; - box-sizing: border-box; - background-color: rgba(8, 7, 48, 0.8); - margin-top: 24px; - opacity: 0; - transition: opacity 0.1s ease; - cursor: default; -} -.svg-funnel-js .svg-funnel-js__labels .svg-funnel-js__label .label__segment-percentages ul { - margin: 0; - padding: 0; - list-style-type: none; -} -.svg-funnel-js .svg-funnel-js__labels .svg-funnel-js__label .label__segment-percentages ul li { - font-size: 13px; - line-height: 16px; - color: #fff; - margin: 18px 0; -} -.svg-funnel-js - .svg-funnel-js__labels - .svg-funnel-js__label - .label__segment-percentages - ul - li - .percentage__list-label { - font-weight: bold; - color: #05df9d; -} -.svg-funnel-js .svg-funnel-js__labels .svg-funnel-js__label:hover .label__segment-percentages { - opacity: 1; -} -.svg-funnel-js:not(.svg-funnel-js--vertical) { - padding-top: 64px; - padding-bottom: 16px; -} -.svg-funnel-js:not(.svg-funnel-js--vertical) .svg-funnel-js__label { - padding-left: 24px; -} -.svg-funnel-js:not(.svg-funnel-js--vertical) .svg-funnel-js__label:not(:first-child) { - border-left: 1px solid #9896dc; -} -.svg-funnel-js.svg-funnel-js--vertical { - padding-left: 120px; - padding-right: 16px; -} -.svg-funnel-js.svg-funnel-js--vertical .svg-funnel-js__label { - padding-top: 24px; -} -.svg-funnel-js.svg-funnel-js--vertical .svg-funnel-js__label:not(:first-child) { - border-top: 1px solid #9896dc; -} -.svg-funnel-js.svg-funnel-js--vertical .svg-funnel-js__label .label__segment-percentages { - margin-top: 0; - margin-left: 106px; - width: calc(100% - 106px); -} -.svg-funnel-js.svg-funnel-js--vertical - .svg-funnel-js__label - .label__segment-percentages - .segment-percentage__list { - display: flex; - justify-content: space-around; -} -.svg-funnel-js .svg-funnel-js__subLabels { - display: flex; - justify-content: center; - margin-top: 24px; - position: absolute; - width: 100%; - left: 0; -} -.svg-funnel-js .svg-funnel-js__subLabels .svg-funnel-js__subLabel { - display: flex; - font-size: 12px; - color: #fff; - line-height: 16px; -} -.svg-funnel-js .svg-funnel-js__subLabels .svg-funnel-js__subLabel:not(:first-child) { - margin-left: 16px; -} -.svg-funnel-js .svg-funnel-js__subLabels .svg-funnel-js__subLabel .svg-funnel-js__subLabel--color { - width: 12px; - height: 12px; - border-radius: 50%; - margin: 2px 8px 2px 0; -} From 4df7d6a2a12d0bcc43d0d1e6fc8ddb2fee647b22 Mon Sep 17 00:00:00 2001 From: Brian Cao Date: Thu, 18 May 2023 13:13:18 -0700 Subject: [PATCH 6/6] Add userReport api --- lib/auth.ts | 31 +++++++++++++++++-- pages/api/reports/[id].ts | 60 +++++++++++++++++++++++++++++++++++++ pages/api/reports/index.ts | 43 ++++++++++++++++++++++++++ queries/admin/user.ts | 14 +++++++++ queries/admin/userReport.ts | 37 +++++++++++++++++++++++ queries/admin/website.ts | 5 ++++ queries/index.js | 1 + 7 files changed, 189 insertions(+), 2 deletions(-) create mode 100644 pages/api/reports/[id].ts create mode 100644 pages/api/reports/index.ts create mode 100644 queries/admin/userReport.ts diff --git a/lib/auth.ts b/lib/auth.ts index 2195ad8f..4d11a289 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -15,6 +15,7 @@ import { getTeamWebsite, getTeamWebsiteByTeamMemberId } from 'queries/admin/team import { validate } from 'uuid'; import { Auth } from './types'; import { loadWebsite } from './query'; +import { UserReport } from '@prisma/client'; const log = debug('umami:auth'); @@ -135,7 +136,34 @@ export async function canDeleteWebsite({ user }: Auth, websiteId: string) { return false; } -// To-do: Implement when payments are setup. +export async function canViewUserReport(auth: Auth, userReport: UserReport) { + if (auth.user.isAdmin) { + return true; + } + + if ((auth.user.id = userReport.userId)) { + return true; + } + + if (await canViewWebsite(auth, userReport.websiteId)) { + return true; + } + + return false; +} + +export async function canUpdateUserReport(auth: Auth, userReport: UserReport) { + if (auth.user.isAdmin) { + return true; + } + + if ((auth.user.id = userReport.userId)) { + return true; + } + + return false; +} + export async function canCreateTeam({ user }: Auth) { if (user.isAdmin) { return true; @@ -144,7 +172,6 @@ export async function canCreateTeam({ user }: Auth) { return !!user; } -// To-do: Implement when payments are setup. export async function canViewTeam({ user }: Auth, teamId: string) { if (user.isAdmin) { return true; diff --git a/pages/api/reports/[id].ts b/pages/api/reports/[id].ts new file mode 100644 index 00000000..42002d18 --- /dev/null +++ b/pages/api/reports/[id].ts @@ -0,0 +1,60 @@ +import { canUpdateUserReport, canViewUserReport } from 'lib/auth'; +import { useAuth, useCors } from 'lib/middleware'; +import { NextApiRequestQueryBody } from 'lib/types'; +import { NextApiResponse } from 'next'; +import { methodNotAllowed, ok, unauthorized } from 'next-basics'; +import { getUserReportById, updateUserReport } from 'queries'; + +export interface UserReportRequestQuery { + id: string; +} + +export interface UserReportRequestBody { + websiteId: string; + reportName: string; + templateName: string; + parameters: string; +} + +export default async ( + req: NextApiRequestQueryBody, + res: NextApiResponse, +) => { + await useCors(req, res); + await useAuth(req, res); + + if (req.method === 'GET') { + const { id: userReportId } = req.query; + + const data = await getUserReportById(userReportId); + + if (!(await canViewUserReport(req.auth, data))) { + return unauthorized(res); + } + + return ok(res, data); + } + + if (req.method === 'POST') { + const { id: userReportId } = req.query; + + const data = await getUserReportById(userReportId); + + if (!(await canUpdateUserReport(req.auth, data))) { + return unauthorized(res); + } + + const updated = await updateUserReport( + { + ...req.body, + }, + { + id: userReportId, + }, + ); + + return ok(res, updated); + } + + return methodNotAllowed(res); +}; diff --git a/pages/api/reports/index.ts b/pages/api/reports/index.ts new file mode 100644 index 00000000..e5855355 --- /dev/null +++ b/pages/api/reports/index.ts @@ -0,0 +1,43 @@ +import { uuid } from 'lib/crypto'; +import { useAuth, useCors } from 'lib/middleware'; +import { NextApiRequestQueryBody } from 'lib/types'; +import { NextApiResponse } from 'next'; +import { methodNotAllowed, ok } from 'next-basics'; +import { createUserReport, getUserReports } from 'queries'; + +export interface UserReportRequestBody { + websiteId: string; + reportName: string; + templateName: string; + parameters: string; +} + +export default async ( + req: NextApiRequestQueryBody, + res: NextApiResponse, +) => { + await useCors(req, res); + await useAuth(req, res); + + const { + user: { id: userId }, + } = req.auth; + + if (req.method === 'GET') { + const data = await getUserReports(userId); + + return ok(res, data); + } + + if (req.method === 'POST') { + const data = await createUserReport({ + id: uuid(), + userId, + ...req.body, + }); + + return ok(res, data); + } + + return methodNotAllowed(res); +}; diff --git a/queries/admin/user.ts b/queries/admin/user.ts index a81a76ef..b7f452c7 100644 --- a/queries/admin/user.ts +++ b/queries/admin/user.ts @@ -210,6 +210,20 @@ export async function deleteUser( }, }, }), + client.userReport.deleteMany({ + where: { + OR: [ + { + websiteId: { + in: websiteIds, + }, + }, + { + userId, + }, + ], + }, + }), cloudMode ? client.website.updateMany({ data: { diff --git a/queries/admin/userReport.ts b/queries/admin/userReport.ts new file mode 100644 index 00000000..d31b512e --- /dev/null +++ b/queries/admin/userReport.ts @@ -0,0 +1,37 @@ +import { Prisma, UserReport } from '@prisma/client'; +import prisma from 'lib/prisma'; + +export async function createUserReport( + data: Prisma.UserReportUncheckedCreateInput, +): Promise { + return prisma.client.userReport.create({ data }); +} + +export async function getUserReportById(userReportId: string): Promise { + return prisma.client.userReport.findUnique({ + where: { + id: userReportId, + }, + }); +} + +export async function getUserReports(userId: string): Promise { + return prisma.client.userReport.findMany({ + where: { + userId, + }, + }); +} + +export async function updateUserReport( + data: Prisma.UserReportUpdateInput, + where: Prisma.UserReportWhereUniqueInput, +): Promise { + return prisma.client.userReport.update({ data, where }); +} + +export async function deleteUserReport( + where: Prisma.UserReportWhereUniqueInput, +): Promise { + return prisma.client.userReport.delete({ where }); +} diff --git a/queries/admin/website.ts b/queries/admin/website.ts index f5ce5739..e6d53fce 100644 --- a/queries/admin/website.ts +++ b/queries/admin/website.ts @@ -92,6 +92,11 @@ export async function deleteWebsite( websiteId, }, }), + client.userReport.deleteMany({ + where: { + websiteId, + }, + }), cloudMode ? prisma.client.website.update({ data: { diff --git a/queries/index.js b/queries/index.js index 5c295fff..e565df25 100644 --- a/queries/index.js +++ b/queries/index.js @@ -1,6 +1,7 @@ export * from './admin/team'; export * from './admin/teamUser'; export * from './admin/user'; +export * from './admin/userReport'; export * from './admin/website'; export * from './analytics/event/getEventMetrics'; export * from './analytics/event/getEventUsage';