Weekly session data.

This commit is contained in:
Mike Cao 2024-08-16 23:42:26 -07:00
parent fc1fc5807e
commit 53d8548909
13 changed files with 331 additions and 5 deletions

View File

@ -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 }) {
<>
<WebsiteHeader websiteId={websiteId} />
<SessionsMetricsBar websiteId={websiteId} />
<GridRow columns="one">
<WorldMap websiteId={websiteId} style={{ width: 800, margin: '0 auto' }} />
<GridRow columns="two-one">
<WorldMap websiteId={websiteId} />
<SessionsWeekly websiteId={websiteId} />
</GridRow>
<Tabs selectedKey={tab} onSelect={(value: any) => setTab(value)} style={{ marginBottom: 30 }}>
<Item key="activity">{formatMessage(labels.activity)}</Item>

View File

@ -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;
}

View File

@ -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 (
<LoadingPanel {...(props as any)} data={data}>
<div className={styles.week}>
<div className={styles.day}>
<div className={styles.header}>&nbsp;</div>
{Array(24)
.fill(null)
.map((_, i) => {
return (
<div key={i} className={classNames(styles.cell, styles.hour)}>
{i.toString().padStart(2, '0')}
</div>
);
})}
</div>
{data?.map((day: number[], index: number) => {
return (
<div className={styles.day} key={index}>
<div className={styles.header}>
{format(getDayOfWeekAsDate(index), 'EEE', { locale: dateLocale })}
</div>
{day?.map((hour: number) => {
return (
<div key={hour} className={classNames(styles.cell)}>
{hour > 0 && (
<TooltipPopup
label={`${formatMessage(labels.visitors)}: ${hour}`}
position="right"
>
<div className={styles.block} style={{ opacity: hour / max }} />
</TooltipPopup>
)}
</div>
);
})}
</div>
);
})}
</div>
</LoadingPanel>
);
}
export default SessionsWeekly;

View File

@ -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 ? (
<StatusLight color={labelColors?.[0]?.backgroundColor}>
{formatLongNumber(dataPoints?.[0]?.raw)} {dataPoints?.[0]?.label}
</StatusLight>
) : null,
);
};
return <Chart {...props} type={type} tooltip={tooltip} onTooltip={handleTooltip} />;
}

View File

@ -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;
}

View File

@ -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 (
<div className={classNames(styles.panel, className)}>
{isLoading && !isFetched && <Loading icon={loadingIcon} />}
{isLoading && !isFetched && <Loading className={styles.loading} icon={loadingIcon} />}
{error && <ErrorMessage />}
{!error && isEmpty && <Empty />}
{!error && !isEmpty && data && children}

View File

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

View File

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

View File

@ -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;
}

View File

@ -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,

View File

@ -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<ReportsRequestQuery, any>,
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);
};

View File

@ -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;
}

View File

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