From 0bd57bb158d14ca8080133bfc3cec7e2884cd6d2 Mon Sep 17 00:00:00 2001 From: Mike Cao Date: Tue, 13 Aug 2024 21:42:20 -0700 Subject: [PATCH] Added display of session properties. --- .../sessions/SessionProperties.module.css | 25 +++++++ .../sessions/SessionProperties.tsx | 52 +++++++++++++ .../[websiteId]/sessions/SessionsPage.tsx | 14 +++- src/components/hooks/index.ts | 2 + .../hooks/queries/useSessionDataProperties.ts | 20 +++++ .../hooks/queries/useSessionDataValues.ts | 21 ++++++ .../[websiteId]/session-data/properties.ts | 49 ++++++++++++ .../[websiteId]/session-data/values.ts | 50 +++++++++++++ .../sessions/getSessionDataProperties.ts | 74 +++++++++++++++++++ .../sessions/getSessionDataValues.ts | 72 ++++++++++++++++++ 10 files changed, 378 insertions(+), 1 deletion(-) create mode 100644 src/app/(main)/websites/[websiteId]/sessions/SessionProperties.module.css create mode 100644 src/app/(main)/websites/[websiteId]/sessions/SessionProperties.tsx create mode 100644 src/components/hooks/queries/useSessionDataProperties.ts create mode 100644 src/components/hooks/queries/useSessionDataValues.ts create mode 100644 src/pages/api/websites/[websiteId]/session-data/properties.ts create mode 100644 src/pages/api/websites/[websiteId]/session-data/values.ts create mode 100644 src/queries/analytics/sessions/getSessionDataProperties.ts create mode 100644 src/queries/analytics/sessions/getSessionDataValues.ts diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionProperties.module.css b/src/app/(main)/websites/[websiteId]/sessions/SessionProperties.module.css new file mode 100644 index 00000000..4f6a65ec --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/sessions/SessionProperties.module.css @@ -0,0 +1,25 @@ +.container { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(420px, 1fr)); + gap: 60px; + margin-bottom: 40px; +} + +.table { + align-self: start; +} + +.link:hover { + cursor: pointer; + color: var(--primary400); +} + +.title { + text-align: center; + font-weight: bold; + margin: 20px 0; +} + +.chart { + min-height: 620px; +} diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionProperties.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionProperties.tsx new file mode 100644 index 00000000..49b63e74 --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/sessions/SessionProperties.tsx @@ -0,0 +1,52 @@ +import { GridColumn, GridTable } from 'react-basics'; +import { useSessionDataProperties, useSessionDataValues, useMessages } from 'components/hooks'; +import { LoadingPanel } from 'components/common/LoadingPanel'; +import PieChart from 'components/charts/PieChart'; +import { useState } from 'react'; +import { CHART_COLORS } from 'lib/constants'; +import styles from './SessionProperties.module.css'; + +export function SessionProperties({ websiteId }: { websiteId: string }) { + const [propertyName, setPropertyName] = useState(''); + const { formatMessage, labels } = useMessages(); + const { data, isLoading, isFetched, error } = useSessionDataProperties(websiteId); + const { data: values } = useSessionDataValues(websiteId, propertyName); + const chartData = + propertyName && values + ? { + labels: values.map(({ value }) => value), + datasets: [ + { + data: values.map(({ total }) => total), + backgroundColor: CHART_COLORS, + borderWidth: 0, + }, + ], + } + : null; + + return ( + +
+ + + {row => ( +
setPropertyName(row.propertyName)}> + {row.propertyName} +
+ )} +
+ +
+ {propertyName && ( +
+
{propertyName}
+ +
+ )} +
+
+ ); +} + +export default SessionProperties; diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionsPage.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionsPage.tsx index ed02b2a8..1b3ba6dd 100644 --- a/src/app/(main)/websites/[websiteId]/sessions/SessionsPage.tsx +++ b/src/app/(main)/websites/[websiteId]/sessions/SessionsPage.tsx @@ -2,10 +2,17 @@ import WebsiteHeader from '../WebsiteHeader'; import SessionsDataTable from './SessionsDataTable'; import SessionsMetricsBar from './SessionsMetricsBar'; +import SessionProperties from './SessionProperties'; import WorldMap from 'components/metrics/WorldMap'; import { GridRow } from 'components/layout/Grid'; +import { Item, Tabs } from 'react-basics'; +import { useState } from 'react'; +import { useMessages } from 'components/hooks'; export function SessionsPage({ websiteId }) { + const [tab, setTab] = useState('activity'); + const { formatMessage, labels } = useMessages(); + return ( <> @@ -13,7 +20,12 @@ export function SessionsPage({ websiteId }) { - + setTab(value)} style={{ marginBottom: 30 }}> + {formatMessage(labels.activity)} + {formatMessage(labels.properties)} + + {tab === 'activity' && } + {tab === 'properties' && } ); } diff --git a/src/components/hooks/index.ts b/src/components/hooks/index.ts index 42cac81a..4e9c49d6 100644 --- a/src/components/hooks/index.ts +++ b/src/components/hooks/index.ts @@ -10,6 +10,8 @@ export * from './queries/useReport'; export * from './queries/useReports'; export * from './queries/useSessionActivity'; export * from './queries/useSessionData'; +export * from './queries/useSessionDataProperties'; +export * from './queries/useSessionDataValues'; export * from './queries/useWebsiteSession'; export * from './queries/useWebsiteSessions'; export * from './queries/useShareToken'; diff --git a/src/components/hooks/queries/useSessionDataProperties.ts b/src/components/hooks/queries/useSessionDataProperties.ts new file mode 100644 index 00000000..459dccd6 --- /dev/null +++ b/src/components/hooks/queries/useSessionDataProperties.ts @@ -0,0 +1,20 @@ +import useApi from './useApi'; +import { UseQueryOptions } from '@tanstack/react-query'; +import { useFilterParams } from '../useFilterParams'; + +export function useSessionDataProperties( + websiteId: string, + options?: Omit, +) { + const { get, useQuery } = useApi(); + const params = useFilterParams(websiteId); + + return useQuery({ + queryKey: ['websites:event-data:properties', { websiteId, ...params }], + queryFn: () => get(`/websites/${websiteId}/session-data/properties`, { ...params }), + enabled: !!websiteId, + ...options, + }); +} + +export default useSessionDataProperties; diff --git a/src/components/hooks/queries/useSessionDataValues.ts b/src/components/hooks/queries/useSessionDataValues.ts new file mode 100644 index 00000000..52300177 --- /dev/null +++ b/src/components/hooks/queries/useSessionDataValues.ts @@ -0,0 +1,21 @@ +import useApi from './useApi'; +import { UseQueryOptions } from '@tanstack/react-query'; +import { useFilterParams } from '../useFilterParams'; + +export function useSessionDataValues( + websiteId: string, + propertyName: string, + options?: Omit, +) { + const { get, useQuery } = useApi(); + const params = useFilterParams(websiteId); + + return useQuery({ + queryKey: ['websites:event-data:values', { websiteId, propertyName, ...params }], + queryFn: () => get(`/websites/${websiteId}/session-data/values`, { ...params, propertyName }), + enabled: !!(websiteId && propertyName), + ...options, + }); +} + +export default useSessionDataValues; diff --git a/src/pages/api/websites/[websiteId]/session-data/properties.ts b/src/pages/api/websites/[websiteId]/session-data/properties.ts new file mode 100644 index 00000000..19e9bbb8 --- /dev/null +++ b/src/pages/api/websites/[websiteId]/session-data/properties.ts @@ -0,0 +1,49 @@ +import { canViewWebsite } from 'lib/auth'; +import { useAuth, useCors, useValidate } from 'lib/middleware'; +import { NextApiRequestQueryBody } from 'lib/types'; +import { NextApiResponse } from 'next'; +import { methodNotAllowed, ok, unauthorized } from 'next-basics'; +import { getEventDataProperties } from 'queries'; +import * as yup from 'yup'; + +export interface EventDataFieldsRequestQuery { + websiteId: string; + startAt: string; + endAt: string; + propertyName?: string; +} + +const schema = { + GET: yup.object().shape({ + websiteId: yup.string().uuid().required(), + startAt: yup.number().integer().required(), + endAt: yup.number().integer().min(yup.ref('startAt')).required(), + propertyName: yup.string(), + }), +}; + +export default async ( + req: NextApiRequestQueryBody, + res: NextApiResponse, +) => { + await useCors(req, res); + await useAuth(req, res); + await useValidate(schema, req, res); + + if (req.method === 'GET') { + const { websiteId, startAt, endAt, propertyName } = req.query; + + if (!(await canViewWebsite(req.auth, websiteId))) { + return unauthorized(res); + } + + const startDate = new Date(+startAt); + const endDate = new Date(+endAt); + + const data = await getEventDataProperties(websiteId, { startDate, endDate, propertyName }); + + return ok(res, data); + } + + return methodNotAllowed(res); +}; diff --git a/src/pages/api/websites/[websiteId]/session-data/values.ts b/src/pages/api/websites/[websiteId]/session-data/values.ts new file mode 100644 index 00000000..b9ada96d --- /dev/null +++ b/src/pages/api/websites/[websiteId]/session-data/values.ts @@ -0,0 +1,50 @@ +import { canViewWebsite } from 'lib/auth'; +import { useAuth, useCors, useValidate } from 'lib/middleware'; +import { NextApiRequestQueryBody } from 'lib/types'; +import { NextApiResponse } from 'next'; +import { methodNotAllowed, ok, unauthorized } from 'next-basics'; +import { getEventDataValues } from 'queries'; + +import * as yup from 'yup'; + +export interface EventDataFieldsRequestQuery { + websiteId: string; + startAt: string; + endAt: string; + propertyName?: string; +} + +const schema = { + GET: yup.object().shape({ + websiteId: yup.string().uuid().required(), + startAt: yup.number().integer().required(), + endAt: yup.number().integer().min(yup.ref('startAt')).required(), + propertyName: yup.string(), + }), +}; + +export default async ( + req: NextApiRequestQueryBody, + res: NextApiResponse, +) => { + await useCors(req, res); + await useAuth(req, res); + await useValidate(schema, req, res); + + if (req.method === 'GET') { + const { websiteId, startAt, endAt, propertyName } = req.query; + + if (!(await canViewWebsite(req.auth, websiteId))) { + return unauthorized(res); + } + + const startDate = new Date(+startAt); + const endDate = new Date(+endAt); + + const data = await getEventDataValues(websiteId, { startDate, endDate, propertyName }); + + return ok(res, data); + } + + return methodNotAllowed(res); +}; diff --git a/src/queries/analytics/sessions/getSessionDataProperties.ts b/src/queries/analytics/sessions/getSessionDataProperties.ts new file mode 100644 index 00000000..0f45e140 --- /dev/null +++ b/src/queries/analytics/sessions/getSessionDataProperties.ts @@ -0,0 +1,74 @@ +import prisma from 'lib/prisma'; +import clickhouse from 'lib/clickhouse'; +import { CLICKHOUSE, PRISMA, runQuery } from 'lib/db'; +import { QueryFilters, WebsiteEventData } from 'lib/types'; + +export async function getSessionDataProperties( + ...args: [websiteId: string, filters: QueryFilters & { propertyName?: string }] +): Promise { + return runQuery({ + [PRISMA]: () => relationalQuery(...args), + [CLICKHOUSE]: () => clickhouseQuery(...args), + }); +} + +async function relationalQuery( + websiteId: string, + filters: QueryFilters & { propertyName?: string }, +) { + const { rawQuery, parseFilters } = prisma; + const { filterQuery, params } = await parseFilters(websiteId, filters, { + columns: { propertyName: 'data_key' }, + }); + + return rawQuery( + ` + select + data_key as "propertyName", + count(*) as "total" + from session_data + where website_id = {{websiteId::uuid}} + and created_at between {{startDate}} and {{endDate}} + ${filterQuery} + group by data_key + order by 2 desc + limit 500 + `, + params, + ); +} + +async function clickhouseQuery( + websiteId: string, + filters: QueryFilters & { propertyName?: string }, +): Promise<{ propertyName: string; dataType: number; propertyValue: string; total: number }[]> { + const { rawQuery, parseFilters } = clickhouse; + const { filterQuery, params } = await parseFilters(websiteId, filters, { + columns: { propertyName: 'data_key' }, + }); + + return rawQuery( + ` + select + data_key as propertyName, + count(*) as total + from session_data + where website_id = {websiteId:UUID} + and created_at between {startDate:DateTime64} and {endDate:DateTime64} + ${filterQuery} + group by data_key + order by 2 desc + limit 500 + `, + params, + ).then(result => { + return Object.values(result).map((a: any) => { + return { + propertyName: a.propertyName, + dataType: Number(a.dataType), + propertyValue: a.propertyValue, + total: Number(a.total), + }; + }); + }); +} diff --git a/src/queries/analytics/sessions/getSessionDataValues.ts b/src/queries/analytics/sessions/getSessionDataValues.ts new file mode 100644 index 00000000..a018dfa0 --- /dev/null +++ b/src/queries/analytics/sessions/getSessionDataValues.ts @@ -0,0 +1,72 @@ +import prisma from 'lib/prisma'; +import clickhouse from 'lib/clickhouse'; +import { CLICKHOUSE, PRISMA, runQuery } from 'lib/db'; +import { QueryFilters, WebsiteEventData } from 'lib/types'; + +export async function getSessionDataValues( + ...args: [websiteId: string, filters: QueryFilters & { propertyName?: string }] +): Promise { + return runQuery({ + [PRISMA]: () => relationalQuery(...args), + [CLICKHOUSE]: () => clickhouseQuery(...args), + }); +} + +async function relationalQuery( + websiteId: string, + filters: QueryFilters & { propertyName?: string }, +) { + const { rawQuery, parseFilters } = prisma; + const { filterQuery, params } = await parseFilters(websiteId, filters); + + return rawQuery( + ` + select + string_value as "value", + count(*) as "total" + from session_data + where website_id = {{websiteId::uuid}} + and created_at between {{startDate}} and {{endDate}} + and data_key = {{propertyName}} + ${filterQuery} + group by string_value + order by 2 desc + limit 500 + `, + params, + ); +} + +async function clickhouseQuery( + websiteId: string, + filters: QueryFilters & { propertyName?: string }, +): Promise<{ propertyName: string; dataType: number; propertyValue: string; total: number }[]> { + const { rawQuery, parseFilters } = clickhouse; + const { filterQuery, params } = await parseFilters(websiteId, filters); + + return rawQuery( + ` + select + multiIf(data_type = 2, replaceAll(string_value, '.0000', ''), + data_type = 4, toString(date_trunc('hour', date_value)), + string_value) as "value", + count(*) as "total" + from umami.session_data + where website_id = {websiteId:UUID} + and created_at between {startDate:DateTime64} and {endDate:DateTime64} + and data_key = {propertyName:String} + ${filterQuery} + group by value + order by 2 desc + limit 500; + `, + params, + ).then(result => { + return Object.values(result).map((a: any) => { + return { + ...a, + total: Number(a.total), + }; + }); + }); +}