mirror of
https://github.com/kremalicious/umami.git
synced 2025-02-14 21:10:34 +01:00
Weekly session data.
This commit is contained in:
parent
fc1fc5807e
commit
53d8548909
@ -8,6 +8,7 @@ import { GridRow } from 'components/layout/Grid';
|
|||||||
import { Item, Tabs } from 'react-basics';
|
import { Item, Tabs } from 'react-basics';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useMessages } from 'components/hooks';
|
import { useMessages } from 'components/hooks';
|
||||||
|
import SessionsWeekly from './SessionsWeekly';
|
||||||
|
|
||||||
export function SessionsPage({ websiteId }) {
|
export function SessionsPage({ websiteId }) {
|
||||||
const [tab, setTab] = useState('activity');
|
const [tab, setTab] = useState('activity');
|
||||||
@ -17,8 +18,9 @@ export function SessionsPage({ websiteId }) {
|
|||||||
<>
|
<>
|
||||||
<WebsiteHeader websiteId={websiteId} />
|
<WebsiteHeader websiteId={websiteId} />
|
||||||
<SessionsMetricsBar websiteId={websiteId} />
|
<SessionsMetricsBar websiteId={websiteId} />
|
||||||
<GridRow columns="one">
|
<GridRow columns="two-one">
|
||||||
<WorldMap websiteId={websiteId} style={{ width: 800, margin: '0 auto' }} />
|
<WorldMap websiteId={websiteId} />
|
||||||
|
<SessionsWeekly websiteId={websiteId} />
|
||||||
</GridRow>
|
</GridRow>
|
||||||
<Tabs selectedKey={tab} onSelect={(value: any) => setTab(value)} style={{ marginBottom: 30 }}>
|
<Tabs selectedKey={tab} onSelect={(value: any) => setTab(value)} style={{ marginBottom: 30 }}>
|
||||||
<Item key="activity">{formatMessage(labels.activity)}</Item>
|
<Item key="activity">{formatMessage(labels.activity)}</Item>
|
||||||
|
@ -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;
|
||||||
|
}
|
@ -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}> </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;
|
27
src/components/charts/BubbleChart.tsx
Normal file
27
src/components/charts/BubbleChart.tsx
Normal 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} />;
|
||||||
|
}
|
@ -3,4 +3,14 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
position: relative;
|
position: relative;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
margin: auto;
|
||||||
}
|
}
|
||||||
|
@ -1,9 +1,9 @@
|
|||||||
import { ReactNode } from 'react';
|
import { ReactNode } from 'react';
|
||||||
import styles from './LoadingPanel.module.css';
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import ErrorMessage from 'components/common/ErrorMessage';
|
|
||||||
import { Loading } from 'react-basics';
|
import { Loading } from 'react-basics';
|
||||||
|
import ErrorMessage from 'components/common/ErrorMessage';
|
||||||
import Empty from 'components/common/Empty';
|
import Empty from 'components/common/Empty';
|
||||||
|
import styles from './LoadingPanel.module.css';
|
||||||
|
|
||||||
export function LoadingPanel({
|
export function LoadingPanel({
|
||||||
data,
|
data,
|
||||||
@ -27,7 +27,7 @@ export function LoadingPanel({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classNames(styles.panel, className)}>
|
<div className={classNames(styles.panel, className)}>
|
||||||
{isLoading && !isFetched && <Loading icon={loadingIcon} />}
|
{isLoading && !isFetched && <Loading className={styles.loading} icon={loadingIcon} />}
|
||||||
{error && <ErrorMessage />}
|
{error && <ErrorMessage />}
|
||||||
{!error && isEmpty && <Empty />}
|
{!error && isEmpty && <Empty />}
|
||||||
{!error && !isEmpty && data && children}
|
{!error && !isEmpty && data && children}
|
||||||
|
@ -14,6 +14,7 @@ export * from './queries/useSessionDataProperties';
|
|||||||
export * from './queries/useSessionDataValues';
|
export * from './queries/useSessionDataValues';
|
||||||
export * from './queries/useWebsiteSession';
|
export * from './queries/useWebsiteSession';
|
||||||
export * from './queries/useWebsiteSessions';
|
export * from './queries/useWebsiteSessions';
|
||||||
|
export * from './queries/useWebsiteSessionsWeekly';
|
||||||
export * from './queries/useShareToken';
|
export * from './queries/useShareToken';
|
||||||
export * from './queries/useTeam';
|
export * from './queries/useTeam';
|
||||||
export * from './queries/useTeams';
|
export * from './queries/useTeams';
|
||||||
|
24
src/components/hooks/queries/useWebsiteSessionsWeekly.ts
Normal file
24
src/components/hooks/queries/useWebsiteSessionsWeekly.ts
Normal 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;
|
@ -34,6 +34,7 @@ import {
|
|||||||
addWeeks,
|
addWeeks,
|
||||||
subWeeks,
|
subWeeks,
|
||||||
endOfMinute,
|
endOfMinute,
|
||||||
|
isSameDay,
|
||||||
} from 'date-fns';
|
} from 'date-fns';
|
||||||
import { getDateLocale } from 'lib/lang';
|
import { getDateLocale } from 'lib/lang';
|
||||||
import { DateRange } from 'lib/types';
|
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) };
|
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;
|
||||||
|
}
|
||||||
|
@ -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) {
|
export function getTimestampSQL(field: string) {
|
||||||
const db = getDatabaseType();
|
const db = getDatabaseType();
|
||||||
|
|
||||||
@ -284,6 +296,7 @@ export default {
|
|||||||
getCastColumnQuery,
|
getCastColumnQuery,
|
||||||
getDayDiffQuery,
|
getDayDiffQuery,
|
||||||
getDateSQL,
|
getDateSQL,
|
||||||
|
getDateWeeklySQL,
|
||||||
getFilterQuery,
|
getFilterQuery,
|
||||||
getSearchParameters,
|
getSearchParameters,
|
||||||
getTimestampDiffSQL,
|
getTimestampDiffSQL,
|
||||||
|
47
src/pages/api/websites/[websiteId]/sessions/weekly.ts
Normal file
47
src/pages/api/websites/[websiteId]/sessions/weekly.ts
Normal 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);
|
||||||
|
};
|
69
src/queries/analytics/sessions/getWebsiteSessionsWeekly.ts
Normal file
69
src/queries/analytics/sessions/getWebsiteSessionsWeekly.ts
Normal 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;
|
||||||
|
}
|
@ -26,6 +26,7 @@ export * from './analytics/sessions/getSessionDataProperties';
|
|||||||
export * from './analytics/sessions/getSessionDataValues';
|
export * from './analytics/sessions/getSessionDataValues';
|
||||||
export * from './analytics/sessions/getSessionMetrics';
|
export * from './analytics/sessions/getSessionMetrics';
|
||||||
export * from './analytics/sessions/getWebsiteSessions';
|
export * from './analytics/sessions/getWebsiteSessions';
|
||||||
|
export * from './analytics/sessions/getWebsiteSessionsWeekly';
|
||||||
export * from './analytics/sessions/getSessionActivity';
|
export * from './analytics/sessions/getSessionActivity';
|
||||||
export * from './analytics/sessions/getSessionStats';
|
export * from './analytics/sessions/getSessionStats';
|
||||||
export * from './analytics/sessions/saveSessionData';
|
export * from './analytics/sessions/saveSessionData';
|
||||||
|
Loading…
x
Reference in New Issue
Block a user