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 { 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>
|
||||
|
@ -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;
|
||||
position: relative;
|
||||
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 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}
|
||||
|
@ -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';
|
||||
|
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,
|
||||
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;
|
||||
}
|
||||
|
@ -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,
|
||||
|
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/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';
|
||||
|
Loading…
x
Reference in New Issue
Block a user