- {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/useSessionActivity.ts b/src/components/hooks/queries/useSessionActivity.ts
index 94676a99..16c139ab 100644
--- a/src/components/hooks/queries/useSessionActivity.ts
+++ b/src/components/hooks/queries/useSessionActivity.ts
@@ -3,15 +3,19 @@ import { useApi } from './useApi';
export function useSessionActivity(
websiteId: string,
sessionId: string,
- startDate: string,
- endDate: string,
+ startDate: Date,
+ endDate: Date,
) {
const { get, useQuery } = useApi();
return useQuery({
- queryKey: ['session:activity', { websiteId, sessionId }],
+ queryKey: ['session:activity', { websiteId, sessionId, startDate, endDate }],
queryFn: () => {
- return get(`/websites/${websiteId}/sessions/${sessionId}/activity`, { startDate, endDate });
+ return get(`/websites/${websiteId}/sessions/${sessionId}/activity`, {
+ startAt: +new Date(startDate),
+ endAt: +new Date(endDate),
+ });
},
+ enabled: Boolean(websiteId && sessionId && startDate && endDate),
});
}
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/components/hooks/useFilterParams.ts b/src/components/hooks/useFilterParams.ts
index 343aea9f..525f3492 100644
--- a/src/components/hooks/useFilterParams.ts
+++ b/src/components/hooks/useFilterParams.ts
@@ -1,19 +1,18 @@
import { useNavigation } from './useNavigation';
import { useDateRange } from './useDateRange';
import { useTimezone } from './useTimezone';
-import { zonedTimeToUtc } from 'date-fns-tz';
export function useFilterParams(websiteId: string) {
const { dateRange } = useDateRange(websiteId);
const { startDate, endDate, unit } = dateRange;
- const { timezone } = useTimezone();
+ const { timezone, toUtc } = useTimezone();
const {
query: { url, referrer, title, query, host, os, browser, device, country, region, city, event },
} = useNavigation();
return {
- startAt: +zonedTimeToUtc(startDate, timezone),
- endAt: +zonedTimeToUtc(endDate, timezone),
+ startAt: +toUtc(startDate),
+ endAt: +toUtc(endDate),
unit,
timezone,
url,
diff --git a/src/components/hooks/useTimezone.ts b/src/components/hooks/useTimezone.ts
index b5e58ea9..24cef02c 100644
--- a/src/components/hooks/useTimezone.ts
+++ b/src/components/hooks/useTimezone.ts
@@ -1,6 +1,6 @@
import { setItem } from 'next-basics';
import { TIMEZONE_CONFIG } from 'lib/constants';
-import { formatInTimeZone } from 'date-fns-tz';
+import { formatInTimeZone, zonedTimeToUtc, utcToZonedTime } from 'date-fns-tz';
import useStore, { setTimezone } from 'store/app';
const selector = (state: { timezone: string }) => state.timezone;
@@ -23,7 +23,15 @@ export function useTimezone() {
);
};
- return { timezone, saveTimezone, formatDate };
+ const toUtc = (date: Date | string | number) => {
+ return zonedTimeToUtc(date, timezone);
+ };
+
+ const fromUtc = (date: Date | string | number) => {
+ return utcToZonedTime(date, timezone);
+ };
+
+ return { timezone, saveTimezone, formatDate, toUtc, fromUtc };
}
export default useTimezone;
diff --git a/src/declaration.d.ts b/src/declaration.d.ts
index d968c14d..986adf27 100644
--- a/src/declaration.d.ts
+++ b/src/declaration.d.ts
@@ -1,5 +1,4 @@
declare module 'cors';
-declare module 'dateformat';
declare module 'debug';
declare module 'chartjs-adapter-date-fns';
declare module 'md5';
diff --git a/src/lib/clickhouse.ts b/src/lib/clickhouse.ts
index 78f0323e..474417b9 100644
--- a/src/lib/clickhouse.ts
+++ b/src/lib/clickhouse.ts
@@ -1,4 +1,5 @@
import { ClickHouseClient, createClient } from '@clickhouse/client';
+import { formatInTimeZone } from 'date-fns-tz';
import debug from 'debug';
import { CLICKHOUSE } from 'lib/db';
import { DEFAULT_PAGE_SIZE, OPERATORS } from './constants';
@@ -8,6 +9,7 @@ import { filtersToArray } from './params';
import { PageParams, QueryFilters, QueryOptions } from './types';
export const CLICKHOUSE_DATE_FORMATS = {
+ utc: '%Y-%m-%dT%H:%i:%SZ',
second: '%Y-%m-%d %H:%i:%S',
minute: '%Y-%m-%d %H:%i:00',
hour: '%Y-%m-%d %H:00:00',
@@ -47,7 +49,11 @@ function getClient() {
return client;
}
-function getDateStringSQL(data: any, unit: string | number, timezone?: string) {
+function getUTCString(date?: Date) {
+ return formatInTimeZone(date || new Date(), 'UTC', 'yyyy-MM-dd HH:mm:ss');
+}
+
+function getDateStringSQL(data: any, unit: string = 'utc', timezone?: string) {
if (timezone) {
return `formatDateTime(${data}, '${CLICKHOUSE_DATE_FORMATS[unit]}', '${timezone}')`;
}
@@ -220,6 +226,7 @@ export default {
getDateStringSQL,
getDateSQL,
getFilterQuery,
+ getUTCString,
parseFilters,
pagedQuery,
findUnique,
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]/session-data/properties.ts b/src/pages/api/websites/[websiteId]/session-data/properties.ts
index 4cd2e1e6..92e182d2 100644
--- a/src/pages/api/websites/[websiteId]/session-data/properties.ts
+++ b/src/pages/api/websites/[websiteId]/session-data/properties.ts
@@ -6,7 +6,7 @@ import { methodNotAllowed, ok, unauthorized } from 'next-basics';
import { getSessionDataProperties } from 'queries';
import * as yup from 'yup';
-export interface EventDataFieldsRequestQuery {
+export interface SessionDataFieldsRequestQuery {
websiteId: string;
startAt: string;
endAt: string;
@@ -23,7 +23,7 @@ const schema = {
};
export default async (
- req: NextApiRequestQueryBody
,
+ req: NextApiRequestQueryBody,
res: NextApiResponse,
) => {
await useCors(req, res);
diff --git a/src/pages/api/websites/[websiteId]/sessions/[sessionId]/activity.ts b/src/pages/api/websites/[websiteId]/sessions/[sessionId]/activity.ts
index d1a763fb..2b0fc084 100644
--- a/src/pages/api/websites/[websiteId]/sessions/[sessionId]/activity.ts
+++ b/src/pages/api/websites/[websiteId]/sessions/[sessionId]/activity.ts
@@ -9,16 +9,16 @@ import { getSessionActivity } from 'queries';
export interface SessionActivityRequestQuery extends PageParams {
websiteId: string;
sessionId: string;
- startDate: string;
- endDate: string;
+ startAt: number;
+ endAt: number;
}
const schema = {
GET: yup.object().shape({
websiteId: yup.string().uuid().required(),
sessionId: yup.string().uuid().required(),
- startDate: yup.string().required(),
- endDate: yup.string().required(),
+ startAt: yup.number().integer(),
+ endAt: yup.number().integer(),
}),
};
@@ -30,19 +30,17 @@ export default async (
await useAuth(req, res);
await useValidate(schema, req, res);
- const { websiteId, sessionId, startDate, endDate } = req.query;
+ const { websiteId, sessionId, startAt, endAt } = req.query;
if (req.method === 'GET') {
if (!(await canViewWebsite(req.auth, websiteId))) {
return unauthorized(res);
}
- const data = await getSessionActivity(
- websiteId,
- sessionId,
- new Date(startDate + 'Z'),
- new Date(endDate + 'Z'),
- );
+ const startDate = new Date(+startAt);
+ const endDate = new Date(+endAt);
+
+ const data = await getSessionActivity(websiteId, sessionId, startDate, endDate);
return ok(res, data);
}
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/events/saveEvent.ts b/src/queries/analytics/events/saveEvent.ts
index 5e21e303..6c0f917b 100644
--- a/src/queries/analytics/events/saveEvent.ts
+++ b/src/queries/analytics/events/saveEvent.ts
@@ -135,10 +135,10 @@ async function clickhouseQuery(data: {
city,
...args
} = data;
- const { insert } = clickhouse;
+ const { insert, getUTCString } = clickhouse;
const { sendMessage } = kafka;
const eventId = uuid();
- const createdAt = new Date().toISOString();
+ const createdAt = getUTCString();
const message = {
...args,
diff --git a/src/queries/analytics/sessions/getSessionActivity.ts b/src/queries/analytics/sessions/getSessionActivity.ts
index bb7c141c..c50a82d9 100644
--- a/src/queries/analytics/sessions/getSessionActivity.ts
+++ b/src/queries/analytics/sessions/getSessionActivity.ts
@@ -23,6 +23,7 @@ async function relationalQuery(
websiteId,
createdAt: { gte: startDate, lte: endDate },
},
+ take: 500,
});
}
@@ -37,8 +38,6 @@ async function clickhouseQuery(
return rawQuery(
`
select
- session_id as id,
- website_id as websiteId,
created_at as createdAt,
url_path as urlPath,
url_query as urlQuery,
@@ -52,6 +51,7 @@ async function clickhouseQuery(
and session_id = {sessionId:UUID}
and created_at between {startDate:DateTime64} and {endDate:DateTime64}
order by created_at desc
+ limit 500
`,
{ websiteId, sessionId, startDate, endDate },
);
diff --git a/src/queries/analytics/sessions/getWebsiteSession.ts b/src/queries/analytics/sessions/getWebsiteSession.ts
index 22c18642..6f672e7d 100644
--- a/src/queries/analytics/sessions/getWebsiteSession.ts
+++ b/src/queries/analytics/sessions/getWebsiteSession.ts
@@ -19,7 +19,7 @@ async function relationalQuery(websiteId: string, sessionId: string) {
}
async function clickhouseQuery(websiteId: string, sessionId: string) {
- const { rawQuery } = clickhouse;
+ const { rawQuery, getDateStringSQL } = clickhouse;
return rawQuery(
`
@@ -34,8 +34,8 @@ async function clickhouseQuery(websiteId: string, sessionId: string) {
country,
subdivision1,
city,
- min(min_time) as firstAt,
- max(max_time) as lastAt,
+ ${getDateStringSQL('min(min_time)')} as firstAt,
+ ${getDateStringSQL('max(max_time)')} as lastAt,
uniq(visit_id) visits,
sum(views) as views,
sum(events) as events,
diff --git a/src/queries/analytics/sessions/getWebsiteSessions.ts b/src/queries/analytics/sessions/getWebsiteSessions.ts
index 60f30b6b..1ea3ef49 100644
--- a/src/queries/analytics/sessions/getWebsiteSessions.ts
+++ b/src/queries/analytics/sessions/getWebsiteSessions.ts
@@ -24,7 +24,7 @@ async function relationalQuery(websiteId: string, filters: QueryFilters, pagePar
}
async function clickhouseQuery(websiteId: string, filters: QueryFilters, pageParams?: PageParams) {
- const { pagedQuery, parseFilters } = clickhouse;
+ const { pagedQuery, parseFilters, getDateStringSQL } = clickhouse;
const { params, dateQuery, filterQuery } = await parseFilters(websiteId, filters);
return pagedQuery(
@@ -42,8 +42,8 @@ async function clickhouseQuery(websiteId: string, filters: QueryFilters, pagePar
country,
subdivision1,
city,
- min(min_time) as firstAt,
- max(max_time) as lastAt,
+ ${getDateStringSQL('min(min_time)')} as firstAt,
+ ${getDateStringSQL('max(max_time)')} as lastAt,
uniq(visit_id) as visits,
sumIf(views, event_type = 1) as views
from website_event_stats_hourly
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/analytics/sessions/saveSessionData.ts b/src/queries/analytics/sessions/saveSessionData.ts
index d932f7ed..5259239a 100644
--- a/src/queries/analytics/sessions/saveSessionData.ts
+++ b/src/queries/analytics/sessions/saveSessionData.ts
@@ -80,9 +80,9 @@ async function clickhouseQuery(data: {
}) {
const { websiteId, sessionId, sessionData } = data;
- const { insert } = clickhouse;
+ const { insert, getUTCString } = clickhouse;
const { sendMessages } = kafka;
- const createdAt = new Date().toISOString();
+ const createdAt = getUTCString();
const jsonKeys = flattenJSON(sessionData);
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';