From 53d8548909ea68cdb8ab7c6ef22c798444d0c4b4 Mon Sep 17 00:00:00 2001 From: Mike Cao Date: Fri, 16 Aug 2024 23:42:26 -0700 Subject: [PATCH] Weekly session data. --- .../[websiteId]/sessions/SessionsPage.tsx | 6 +- .../sessions/SessionsWeekly.module.css | 40 ++++++++++ .../[websiteId]/sessions/SessionsWeekly.tsx | 78 +++++++++++++++++++ src/components/charts/BubbleChart.tsx | 27 +++++++ src/components/common/LoadingPanel.module.css | 10 +++ src/components/common/LoadingPanel.tsx | 6 +- src/components/hooks/index.ts | 1 + .../hooks/queries/useWebsiteSessionsWeekly.ts | 24 ++++++ src/lib/date.ts | 14 ++++ src/lib/prisma.ts | 13 ++++ .../websites/[websiteId]/sessions/weekly.ts | 47 +++++++++++ .../sessions/getWebsiteSessionsWeekly.ts | 69 ++++++++++++++++ src/queries/index.ts | 1 + 13 files changed, 331 insertions(+), 5 deletions(-) create mode 100644 src/app/(main)/websites/[websiteId]/sessions/SessionsWeekly.module.css create mode 100644 src/app/(main)/websites/[websiteId]/sessions/SessionsWeekly.tsx create mode 100644 src/components/charts/BubbleChart.tsx create mode 100644 src/components/hooks/queries/useWebsiteSessionsWeekly.ts create mode 100644 src/pages/api/websites/[websiteId]/sessions/weekly.ts create mode 100644 src/queries/analytics/sessions/getWebsiteSessionsWeekly.ts diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionsPage.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionsPage.tsx index 1b3ba6dd..30fd193db 100644 --- a/src/app/(main)/websites/[websiteId]/sessions/SessionsPage.tsx +++ b/src/app/(main)/websites/[websiteId]/sessions/SessionsPage.tsx @@ -8,6 +8,7 @@ import { GridRow } from 'components/layout/Grid'; import { Item, Tabs } from 'react-basics'; import { useState } from 'react'; import { useMessages } from 'components/hooks'; +import SessionsWeekly from './SessionsWeekly'; export function SessionsPage({ websiteId }) { const [tab, setTab] = useState('activity'); @@ -17,8 +18,9 @@ export function SessionsPage({ websiteId }) { <> - - + + + setTab(value)} style={{ marginBottom: 30 }}> {formatMessage(labels.activity)} diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionsWeekly.module.css b/src/app/(main)/websites/[websiteId]/sessions/SessionsWeekly.module.css new file mode 100644 index 00000000..4b41d87c --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/sessions/SessionsWeekly.module.css @@ -0,0 +1,40 @@ +.week { + display: flex; + justify-content: space-between; + position: relative; +} + +.header { + text-align: center; + font-weight: 700; + margin-bottom: 10px; +} + +.day { + display: flex; + flex-direction: column; + gap: 5px; + position: relative; +} + +.cell { + display: flex; + align-items: center; + justify-content: center; + background-color: var(--base100); + width: 20px; + height: 20px; +} + +.hour { + font-weight: 700; + color: var(--font-color300); + background-color: transparent; +} + +.block { + background-color: var(--primary400); + width: 20px; + height: 20px; + border-radius: 3px; +} diff --git a/src/app/(main)/websites/[websiteId]/sessions/SessionsWeekly.tsx b/src/app/(main)/websites/[websiteId]/sessions/SessionsWeekly.tsx new file mode 100644 index 00000000..6c0984ea --- /dev/null +++ b/src/app/(main)/websites/[websiteId]/sessions/SessionsWeekly.tsx @@ -0,0 +1,78 @@ +import { format } from 'date-fns'; +import { useLocale, useMessages, useWebsiteSessionsWeekly } from 'components/hooks'; +import { LoadingPanel } from 'components/common/LoadingPanel'; +import { getDayOfWeekAsDate } from 'lib/date'; +import styles from './SessionsWeekly.module.css'; +import classNames from 'classnames'; +import { TooltipPopup } from 'react-basics'; + +export function SessionsWeekly({ websiteId }: { websiteId: string }) { + const { data, ...props } = useWebsiteSessionsWeekly(websiteId); + const { dateLocale } = useLocale(); + const { labels, formatMessage } = useMessages(); + + const [, max] = data + ? data.reduce((arr: number[], hours: number[], index: number) => { + const min = Math.min(...hours); + const max = Math.max(...hours); + + if (index === 0) { + return [min, max]; + } + + if (min < arr[0]) { + arr[0] = min; + } + + if (max > arr[1]) { + arr[1] = max; + } + + return arr; + }, []) + : []; + + return ( + +
+
+
 
+ {Array(24) + .fill(null) + .map((_, i) => { + return ( +
+ {i.toString().padStart(2, '0')} +
+ ); + })} +
+ {data?.map((day: number[], index: number) => { + return ( +
+
+ {format(getDayOfWeekAsDate(index), 'EEE', { locale: dateLocale })} +
+ {day?.map((hour: number) => { + return ( +
+ {hour > 0 && ( + +
+ + )} +
+ ); + })} +
+ ); + })} +
+ + ); +} + +export default SessionsWeekly; diff --git a/src/components/charts/BubbleChart.tsx b/src/components/charts/BubbleChart.tsx new file mode 100644 index 00000000..956e260c --- /dev/null +++ b/src/components/charts/BubbleChart.tsx @@ -0,0 +1,27 @@ +import { Chart, ChartProps } from 'components/charts/Chart'; +import { useState } from 'react'; +import { StatusLight } from 'react-basics'; +import { formatLongNumber } from 'lib/format'; + +export interface BubbleChartProps extends ChartProps { + type?: 'bubble'; +} + +export default function BubbleChart(props: BubbleChartProps) { + const [tooltip, setTooltip] = useState(null); + const { type = 'bubble' } = props; + + const handleTooltip = ({ tooltip }) => { + const { labelColors, dataPoints } = tooltip; + + setTooltip( + tooltip.opacity ? ( + + {formatLongNumber(dataPoints?.[0]?.raw)} {dataPoints?.[0]?.label} + + ) : null, + ); + }; + + return ; +} diff --git a/src/components/common/LoadingPanel.module.css b/src/components/common/LoadingPanel.module.css index 2dc8b75e..00d6cbb4 100644 --- a/src/components/common/LoadingPanel.module.css +++ b/src/components/common/LoadingPanel.module.css @@ -3,4 +3,14 @@ flex-direction: column; position: relative; flex: 1; + height: 100%; +} + +.loading { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + margin: auto; } diff --git a/src/components/common/LoadingPanel.tsx b/src/components/common/LoadingPanel.tsx index 487252be..36de9365 100644 --- a/src/components/common/LoadingPanel.tsx +++ b/src/components/common/LoadingPanel.tsx @@ -1,9 +1,9 @@ import { ReactNode } from 'react'; -import styles from './LoadingPanel.module.css'; import classNames from 'classnames'; -import ErrorMessage from 'components/common/ErrorMessage'; import { Loading } from 'react-basics'; +import ErrorMessage from 'components/common/ErrorMessage'; import Empty from 'components/common/Empty'; +import styles from './LoadingPanel.module.css'; export function LoadingPanel({ data, @@ -27,7 +27,7 @@ export function LoadingPanel({ return (
- {isLoading && !isFetched && } + {isLoading && !isFetched && } {error && } {!error && isEmpty && } {!error && !isEmpty && data && children} diff --git a/src/components/hooks/index.ts b/src/components/hooks/index.ts index 4e9c49d6..1be99732 100644 --- a/src/components/hooks/index.ts +++ b/src/components/hooks/index.ts @@ -14,6 +14,7 @@ export * from './queries/useSessionDataProperties'; export * from './queries/useSessionDataValues'; export * from './queries/useWebsiteSession'; export * from './queries/useWebsiteSessions'; +export * from './queries/useWebsiteSessionsWeekly'; export * from './queries/useShareToken'; export * from './queries/useTeam'; export * from './queries/useTeams'; diff --git a/src/components/hooks/queries/useWebsiteSessionsWeekly.ts b/src/components/hooks/queries/useWebsiteSessionsWeekly.ts new file mode 100644 index 00000000..5df543f5 --- /dev/null +++ b/src/components/hooks/queries/useWebsiteSessionsWeekly.ts @@ -0,0 +1,24 @@ +import { useApi } from './useApi'; +import useModified from '../useModified'; +import { useFilterParams } from 'components/hooks/useFilterParams'; + +export function useWebsiteSessionsWeekly( + websiteId: string, + params?: { [key: string]: string | number }, +) { + const { get, useQuery } = useApi(); + const { modified } = useModified(`sessions`); + const filters = useFilterParams(websiteId); + + return useQuery({ + queryKey: ['sessions', { websiteId, modified, ...params, ...filters }], + queryFn: () => { + return get(`/websites/${websiteId}/sessions/weekly`, { + ...params, + ...filters, + }); + }, + }); +} + +export default useWebsiteSessionsWeekly; diff --git a/src/lib/date.ts b/src/lib/date.ts index 2fb24073..b8bfa6c7 100644 --- a/src/lib/date.ts +++ b/src/lib/date.ts @@ -34,6 +34,7 @@ import { addWeeks, subWeeks, endOfMinute, + isSameDay, } from 'date-fns'; import { getDateLocale } from 'lib/lang'; import { DateRange } from 'lib/types'; @@ -336,3 +337,16 @@ export function getCompareDate(compare: string, startDate: Date, endDate: Date) return { startDate: subMinutes(startDate, diff), endDate: subMinutes(endDate, diff) }; } + +export function getDayOfWeekAsDate(dayOfWeek: number) { + const startOfWeekDay = startOfWeek(new Date()); + const daysToAdd = [0, 1, 2, 3, 4, 5, 6].indexOf(dayOfWeek); + let currentDate = addDays(startOfWeekDay, daysToAdd); + + // Ensure we're not returning a past date + if (isSameDay(currentDate, startOfWeekDay)) { + currentDate = addDays(currentDate, 7); + } + + return currentDate; +} diff --git a/src/lib/prisma.ts b/src/lib/prisma.ts index 28835414..1fbb3cd4 100644 --- a/src/lib/prisma.ts +++ b/src/lib/prisma.ts @@ -81,6 +81,18 @@ function getDateSQL(field: string, unit: string, timezone?: string): string { } } +function getDateWeeklySQL(field: string) { + const db = getDatabaseType(); + + if (db === POSTGRESQL) { + return `EXTRACT(DOW FROM ${field})`; + } + + if (db === MYSQL) { + return `DAYOFWEEK(${field})-1`; + } +} + export function getTimestampSQL(field: string) { const db = getDatabaseType(); @@ -284,6 +296,7 @@ export default { getCastColumnQuery, getDayDiffQuery, getDateSQL, + getDateWeeklySQL, getFilterQuery, getSearchParameters, getTimestampDiffSQL, diff --git a/src/pages/api/websites/[websiteId]/sessions/weekly.ts b/src/pages/api/websites/[websiteId]/sessions/weekly.ts new file mode 100644 index 00000000..f33970d0 --- /dev/null +++ b/src/pages/api/websites/[websiteId]/sessions/weekly.ts @@ -0,0 +1,47 @@ +import * as yup from 'yup'; +import { canViewWebsite } from 'lib/auth'; +import { useAuth, useCors, useValidate } from 'lib/middleware'; +import { NextApiRequestQueryBody, PageParams } from 'lib/types'; +import { NextApiResponse } from 'next'; +import { methodNotAllowed, ok, unauthorized } from 'next-basics'; +import { pageInfo } from 'lib/schema'; +import { getWebsiteSessionsWeekly } from 'queries'; + +export interface ReportsRequestQuery extends PageParams { + websiteId: 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(), + ...pageInfo, + }), +}; + +export default async ( + req: NextApiRequestQueryBody, + res: NextApiResponse, +) => { + await useCors(req, res); + await useAuth(req, res); + await useValidate(schema, req, res); + + const { websiteId, startAt, endAt } = req.query; + + if (req.method === 'GET') { + if (!(await canViewWebsite(req.auth, websiteId))) { + return unauthorized(res); + } + + const startDate = new Date(+startAt); + const endDate = new Date(+endAt); + + const data = await getWebsiteSessionsWeekly(websiteId, { startDate, endDate }); + + return ok(res, data); + } + + return methodNotAllowed(res); +}; diff --git a/src/queries/analytics/sessions/getWebsiteSessionsWeekly.ts b/src/queries/analytics/sessions/getWebsiteSessionsWeekly.ts new file mode 100644 index 00000000..9031edf5 --- /dev/null +++ b/src/queries/analytics/sessions/getWebsiteSessionsWeekly.ts @@ -0,0 +1,69 @@ +import prisma from 'lib/prisma'; +import clickhouse from 'lib/clickhouse'; +import { runQuery, PRISMA, CLICKHOUSE } from 'lib/db'; +import { QueryFilters } from 'lib/types'; + +export async function getWebsiteSessionsWeekly( + ...args: [websiteId: string, filters?: QueryFilters] +) { + return runQuery({ + [PRISMA]: () => relationalQuery(...args), + [CLICKHOUSE]: () => clickhouseQuery(...args), + }); +} + +async function relationalQuery(websiteId: string, filters: QueryFilters) { + const { rawQuery, getDateWeeklySQL, parseFilters } = prisma; + const { params } = await parseFilters(websiteId, filters); + + return rawQuery( + ` + select + ${getDateWeeklySQL('created_at')} as time, + count(distinct session_id) as value + from website_event_stats_hourly + where website_id = {{websiteId::uuid}} + and created_at between {{startDate}} and {{endDate}} + group by time + order by 2 + `, + params, + ).then(formatResults); +} + +async function clickhouseQuery(websiteId: string, filters: QueryFilters) { + const { rawQuery } = clickhouse; + const { startDate, endDate } = filters; + + return rawQuery( + ` + select + formatDateTime(created_at, '%w:%H') as time, + count(distinct session_id) as value + from website_event_stats_hourly + where website_id = {websiteId:UUID} + and created_at between {startDate:DateTime64} and {endDate:DateTime64} + group by time + order by time + `, + { websiteId, startDate, endDate }, + ).then(formatResults); +} + +function formatResults(data: any) { + const days = []; + + for (let i = 0; i < 7; i++) { + days.push([]); + + for (let j = 0; j < 24; j++) { + days[i].push( + Number( + data.find(({ time }) => time === `${i}:${j.toString().padStart(2, '0')}`)?.value || 0, + ), + ); + } + } + + return days; +} diff --git a/src/queries/index.ts b/src/queries/index.ts index f9c44dba..26c1df09 100644 --- a/src/queries/index.ts +++ b/src/queries/index.ts @@ -26,6 +26,7 @@ export * from './analytics/sessions/getSessionDataProperties'; export * from './analytics/sessions/getSessionDataValues'; export * from './analytics/sessions/getSessionMetrics'; export * from './analytics/sessions/getWebsiteSessions'; +export * from './analytics/sessions/getWebsiteSessionsWeekly'; export * from './analytics/sessions/getSessionActivity'; export * from './analytics/sessions/getSessionStats'; export * from './analytics/sessions/saveSessionData';