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';