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"