feat: add metric bar data
This commit is contained in:
parent
31031dc56b
commit
7ccbb922de
@ -12,7 +12,7 @@
|
||||
<script>
|
||||
const el = document.createElement('script');
|
||||
el.src = location.origin + '/tracker.js';
|
||||
el.setAttribute('data-website-id', '873be511-f7a1-442b-a731-2986a09e28ff'); // For test
|
||||
el.setAttribute('data-website-id', '1003c2f3-5cc0-482d-b081-53d8e4858b5d'); // For test
|
||||
document.head.append(el);
|
||||
</script>
|
||||
</body>
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { DateUnit } from '../../utils/date';
|
||||
import { queryClient } from '../cache';
|
||||
import { request } from '../request';
|
||||
import { getUserTimezone } from './user';
|
||||
@ -126,15 +127,16 @@ export function useWorkspaceWebsitePageview(
|
||||
workspaceId: string,
|
||||
websiteId: string,
|
||||
startAt: number,
|
||||
endAt: number
|
||||
endAt: number,
|
||||
unit: DateUnit
|
||||
) {
|
||||
const { data, isLoading, refetch } = useQuery(
|
||||
['websitePageview', { workspaceId, websiteId }],
|
||||
['websitePageview', { workspaceId, websiteId, startAt, endAt }],
|
||||
() => {
|
||||
return getWorkspaceWebsitePageview(workspaceId, websiteId, {
|
||||
startAt,
|
||||
endAt,
|
||||
unit: 'hour',
|
||||
unit,
|
||||
timezone: getUserTimezone(),
|
||||
});
|
||||
}
|
||||
@ -147,3 +149,59 @@ export function useWorkspaceWebsitePageview(
|
||||
refetch,
|
||||
};
|
||||
}
|
||||
|
||||
export interface StatsItemType {
|
||||
value: number;
|
||||
change: number;
|
||||
}
|
||||
export async function getWorkspaceWebsiteStats(
|
||||
workspaceId: string,
|
||||
websiteId: string,
|
||||
filter: Record<string, any>
|
||||
): Promise<{
|
||||
bounces: StatsItemType;
|
||||
pageviews: StatsItemType;
|
||||
totaltime: StatsItemType;
|
||||
uniques: StatsItemType;
|
||||
}> {
|
||||
const { data } = await request.get(
|
||||
`/api/workspace/${workspaceId}/website/${websiteId}/stats`,
|
||||
{
|
||||
params: {
|
||||
...filter,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return data.stats;
|
||||
}
|
||||
|
||||
export function useWorkspaceWebsiteStats(
|
||||
workspaceId: string,
|
||||
websiteId: string,
|
||||
startAt: number,
|
||||
endAt: number,
|
||||
unit: DateUnit
|
||||
) {
|
||||
const {
|
||||
data: stats,
|
||||
isLoading,
|
||||
refetch,
|
||||
} = useQuery(
|
||||
['websiteStats', { workspaceId, websiteId, startAt, endAt }],
|
||||
() => {
|
||||
return getWorkspaceWebsiteStats(workspaceId, websiteId, {
|
||||
startAt,
|
||||
endAt,
|
||||
unit,
|
||||
timezone: getUserTimezone(),
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
stats,
|
||||
isLoading,
|
||||
refetch,
|
||||
};
|
||||
}
|
||||
|
40
src/client/components/MetricCard.tsx
Normal file
40
src/client/components/MetricCard.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
import { Tag } from 'antd';
|
||||
import React from 'react';
|
||||
import { formatNumber } from '../utils/common';
|
||||
|
||||
interface MetricCardProps {
|
||||
value?: number;
|
||||
change?: number;
|
||||
label: string;
|
||||
reverseColors?: boolean;
|
||||
format?: (n: number) => string;
|
||||
hideComparison?: boolean;
|
||||
}
|
||||
export const MetricCard: React.FC<MetricCardProps> = React.memo((props) => {
|
||||
const {
|
||||
value = 0,
|
||||
change = 0,
|
||||
label,
|
||||
reverseColors = false,
|
||||
format = formatNumber,
|
||||
hideComparison = false,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col justify-center min-w-[140px] min-h-[90px]">
|
||||
<div className="flex items-center whitespace-nowrap font-bold text-4xl">
|
||||
{format(value)}
|
||||
</div>
|
||||
<div className="flex items-center whitespace-nowrap font-bold">
|
||||
<span className="mr-2">{label}</span>
|
||||
{~~change !== 0 && !hideComparison && (
|
||||
<Tag color={change * (reverseColors ? -1 : 1) >= 0 ? 'green' : 'red'}>
|
||||
{change > 0 && '+'}
|
||||
{format(change)}
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
MetricCard.displayName = 'MetricCard';
|
@ -5,7 +5,9 @@ import { ArrowRightOutlined, SyncOutlined } from '@ant-design/icons';
|
||||
import { DateFilter } from './DateFilter';
|
||||
import { HealthBar } from './HealthBar';
|
||||
import {
|
||||
StatsItemType,
|
||||
useWorkspaceWebsitePageview,
|
||||
useWorkspaceWebsiteStats,
|
||||
useWorspaceWebsites,
|
||||
WebsiteInfo,
|
||||
} from '../api/model/website';
|
||||
@ -18,6 +20,8 @@ import {
|
||||
getDateArray,
|
||||
} from '../utils/date';
|
||||
import { useEvent } from '../hooks/useEvent';
|
||||
import { MetricCard } from './MetricCard';
|
||||
import { formatNumber, formatShortTime } from '../utils/common';
|
||||
|
||||
interface WebsiteOverviewProps {
|
||||
workspaceId: string;
|
||||
@ -48,16 +52,33 @@ const WebsiteOverviewItem: React.FC<{
|
||||
const startDate = dayjs().subtract(1, 'day').add(1, unit).startOf(unit);
|
||||
const endDate = dayjs().endOf(unit);
|
||||
|
||||
const { pageviews, sessions, isLoading, refetch } =
|
||||
useWorkspaceWebsitePageview(
|
||||
props.website.workspaceId,
|
||||
props.website.id,
|
||||
startDate.unix() * 1000,
|
||||
endDate.unix() * 1000
|
||||
);
|
||||
const {
|
||||
pageviews,
|
||||
sessions,
|
||||
isLoading: isLoadingPageview,
|
||||
refetch: refetchPageview,
|
||||
} = useWorkspaceWebsitePageview(
|
||||
props.website.workspaceId,
|
||||
props.website.id,
|
||||
startDate.unix() * 1000,
|
||||
endDate.unix() * 1000,
|
||||
unit
|
||||
);
|
||||
|
||||
const {
|
||||
stats,
|
||||
isLoading: isLoadingStats,
|
||||
refetch: refetchStats,
|
||||
} = useWorkspaceWebsiteStats(
|
||||
props.website.workspaceId,
|
||||
props.website.id,
|
||||
startDate.unix() * 1000,
|
||||
endDate.unix() * 1000,
|
||||
unit
|
||||
);
|
||||
|
||||
const handleRefresh = useEvent(async () => {
|
||||
await refetch();
|
||||
await Promise.all([refetchPageview(), refetchStats()]);
|
||||
message.success('Refreshed');
|
||||
});
|
||||
|
||||
@ -71,7 +92,7 @@ const WebsiteOverviewItem: React.FC<{
|
||||
];
|
||||
}, [pageviews, sessions, unit]);
|
||||
|
||||
if (isLoading) {
|
||||
if (isLoadingPageview || isLoadingStats) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
@ -98,17 +119,7 @@ const WebsiteOverviewItem: React.FC<{
|
||||
</div>
|
||||
|
||||
<div className="flex mb-10 flex-wrap">
|
||||
<div className="flex gap-5 flex-wrap w-full lg:w-2/3">
|
||||
<MetricCard label="Views" value={20} diff={20} />
|
||||
<MetricCard label="Visitors" value={20} diff={20} />
|
||||
<MetricCard label="Bounce rate" value={20} diff={-20} unit="%" />
|
||||
<MetricCard
|
||||
label="Average visit time"
|
||||
value={20}
|
||||
diff={-20}
|
||||
unit="s"
|
||||
/>
|
||||
</div>
|
||||
{stats && <MetricsBar stats={stats} />}
|
||||
|
||||
<div className="flex items-center gap-2 justify-end w-full lg:w-1/3">
|
||||
<Button
|
||||
@ -129,30 +140,73 @@ const WebsiteOverviewItem: React.FC<{
|
||||
});
|
||||
WebsiteOverviewItem.displayName = 'WebsiteOverviewItem';
|
||||
|
||||
const MetricCard: React.FC<{
|
||||
label: string;
|
||||
value: number;
|
||||
diff: number;
|
||||
unit?: string;
|
||||
export const MetricsBar: React.FC<{
|
||||
stats: {
|
||||
bounces: StatsItemType;
|
||||
pageviews: StatsItemType;
|
||||
totaltime: StatsItemType;
|
||||
uniques: StatsItemType;
|
||||
};
|
||||
}> = React.memo((props) => {
|
||||
const unit = props.unit ?? '';
|
||||
const { pageviews, uniques, bounces, totaltime } = props.stats || {};
|
||||
const num = Math.min(uniques.value, bounces.value);
|
||||
const diffs = {
|
||||
pageviews: pageviews.value - pageviews.change,
|
||||
uniques: uniques.value - uniques.change,
|
||||
bounces: bounces.value - bounces.change,
|
||||
totaltime: totaltime.value - totaltime.change,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col justify-center min-w-[140px] min-h-[90px]">
|
||||
<div className="flex items-center whitespace-nowrap font-bold text-4xl">
|
||||
{String(props.value)}
|
||||
{unit}
|
||||
</div>
|
||||
<div className="flex items-center whitespace-nowrap font-bold">
|
||||
<span className="mr-2">{props.label}</span>
|
||||
<Tag color={props.diff >= 0 ? 'green' : 'red'}>
|
||||
{props.diff >= 0 ? `+${props.diff}${unit}` : `${props.diff}${unit}`}
|
||||
</Tag>
|
||||
</div>
|
||||
<div className="flex gap-5 flex-wrap w-full lg:w-2/3">
|
||||
<MetricCard
|
||||
label="Views"
|
||||
value={pageviews.value}
|
||||
change={pageviews.change}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Visitors"
|
||||
value={uniques.value}
|
||||
change={uniques.change}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Bounce rate"
|
||||
value={uniques.value ? (num / uniques.value) * 100 : 0}
|
||||
change={
|
||||
uniques.value && uniques.change
|
||||
? (num / uniques.value) * 100 -
|
||||
(Math.min(diffs.uniques, diffs.bounces) / diffs.uniques) *
|
||||
100 || 0
|
||||
: 0
|
||||
}
|
||||
format={(n) => formatNumber(n) + '%'}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Average visit time"
|
||||
value={
|
||||
totaltime.value && pageviews.value
|
||||
? totaltime.value / (pageviews.value - bounces.value)
|
||||
: 0
|
||||
}
|
||||
change={
|
||||
totaltime.value && pageviews.value
|
||||
? (diffs.totaltime / (diffs.pageviews - diffs.bounces) -
|
||||
totaltime.value / (pageviews.value - bounces.value)) *
|
||||
-1 || 0
|
||||
: 0
|
||||
}
|
||||
format={(n) =>
|
||||
`${n < 0 ? '-' : ''}${formatShortTime(
|
||||
Math.abs(~~n),
|
||||
['m', 's'],
|
||||
' '
|
||||
)}`
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
MetricCard.displayName = 'MetricCard';
|
||||
MetricsBar.displayName = 'MetricsBar';
|
||||
|
||||
export const StatsChart: React.FC<{
|
||||
data: { x: string; y: number; type: string }[];
|
||||
|
36
src/client/utils/common.ts
Normal file
36
src/client/utils/common.ts
Normal file
@ -0,0 +1,36 @@
|
||||
export function parseTime(val: number) {
|
||||
const days = ~~(val / 86400);
|
||||
const hours = ~~(val / 3600) - days * 24;
|
||||
const minutes = ~~(val / 60) - days * 1440 - hours * 60;
|
||||
const seconds = ~~val - days * 86400 - hours * 3600 - minutes * 60;
|
||||
const ms = (val - ~~val) * 1000;
|
||||
|
||||
return {
|
||||
days,
|
||||
hours,
|
||||
minutes,
|
||||
seconds,
|
||||
ms,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatNumber(n: number) {
|
||||
return Number(n).toFixed(0);
|
||||
}
|
||||
|
||||
export function formatShortTime(val: number, formats = ['m', 's'], space = '') {
|
||||
const { days, hours, minutes, seconds, ms } = parseTime(val);
|
||||
let t = '';
|
||||
|
||||
if (days > 0 && formats.indexOf('d') !== -1) t += `${days}d${space}`;
|
||||
if (hours > 0 && formats.indexOf('h') !== -1) t += `${hours}h${space}`;
|
||||
if (minutes > 0 && formats.indexOf('m') !== -1) t += `${minutes}m${space}`;
|
||||
if (seconds > 0 && formats.indexOf('s') !== -1) t += `${seconds}s${space}`;
|
||||
if (ms > 0 && formats.indexOf('ms') !== -1) t += `${ms}ms`;
|
||||
|
||||
if (!t) {
|
||||
return `0${formats[formats.length - 1]}`;
|
||||
}
|
||||
|
||||
return t;
|
||||
}
|
@ -1,5 +1,10 @@
|
||||
import { prisma } from './_client';
|
||||
import { QueryFilters, parseFilters, getDateQuery } from '../utils/prisma';
|
||||
import {
|
||||
QueryFilters,
|
||||
parseFilters,
|
||||
getDateQuery,
|
||||
getTimestampIntervalQuery,
|
||||
} from '../utils/prisma';
|
||||
import { DEFAULT_RESET_DATE, EVENT_TYPE } from '../utils/const';
|
||||
|
||||
export async function getWorkspaceUser(workspaceId: string, userId: string) {
|
||||
@ -137,7 +142,7 @@ export async function getWorkspaceWebsiteDateRange(websiteId: string) {
|
||||
};
|
||||
}
|
||||
|
||||
export async function getWorkspaceWebsitePageviewStats(
|
||||
export async function getWorkspaceWebsitePageview(
|
||||
websiteId: string,
|
||||
filters: QueryFilters
|
||||
) {
|
||||
@ -162,7 +167,7 @@ export async function getWorkspaceWebsitePageviewStats(
|
||||
`;
|
||||
}
|
||||
|
||||
export async function getWorkspaceWebsiteSessionStats(
|
||||
export async function getWorkspaceWebsiteSession(
|
||||
websiteId: string,
|
||||
filters: QueryFilters
|
||||
) {
|
||||
@ -186,3 +191,38 @@ export async function getWorkspaceWebsiteSessionStats(
|
||||
group by 1
|
||||
`;
|
||||
}
|
||||
|
||||
export async function getWorkspaceWebsiteStats(
|
||||
websiteId: string,
|
||||
filters: QueryFilters
|
||||
): any {
|
||||
const { filterQuery, joinSession, params } = await parseFilters(websiteId, {
|
||||
...filters,
|
||||
});
|
||||
|
||||
return prisma.$queryRaw`
|
||||
select
|
||||
sum(t.c) as "pageviews",
|
||||
count(distinct t."sessionId") as "uniques",
|
||||
sum(case when t.c = 1 then 1 else 0 end) as "bounces",
|
||||
sum(t.time) as "totaltime"
|
||||
from (
|
||||
select
|
||||
"WebsiteEvent"."sessionId",
|
||||
${getDateQuery('"WebsiteEvent"."createdAt"', 'hour')},
|
||||
count(*) as c,
|
||||
${getTimestampIntervalQuery('"WebsiteEvent"."createdAt"')} as "time"
|
||||
from "WebsiteEvent"
|
||||
join "Website"
|
||||
on "WebsiteEvent"."websiteId" = "Website"."id"
|
||||
${joinSession}
|
||||
where "Website"."id" = ${params.websiteId}::uuid
|
||||
and "WebsiteEvent"."createdAt" between ${
|
||||
params.startDate
|
||||
}::timestamptz and ${params.endDate}::timestamptz
|
||||
and "eventType" = ${EVENT_TYPE.pageView}
|
||||
${filterQuery}
|
||||
group by 1, 2
|
||||
) as t
|
||||
`;
|
||||
}
|
||||
|
@ -1,3 +1,4 @@
|
||||
import dayjs from 'dayjs';
|
||||
import { Router } from 'express';
|
||||
import { auth } from '../middleware/auth';
|
||||
import { body, param, query, validate } from '../middleware/validate';
|
||||
@ -6,9 +7,10 @@ import {
|
||||
addWorkspaceWebsite,
|
||||
deleteWorkspaceWebsite,
|
||||
getWorkspaceWebsiteInfo,
|
||||
getWorkspaceWebsitePageviewStats,
|
||||
getWorkspaceWebsitePageview,
|
||||
getWorkspaceWebsites,
|
||||
getWorkspaceWebsiteSessionStats,
|
||||
getWorkspaceWebsiteSession,
|
||||
getWorkspaceWebsiteStats,
|
||||
updateWorkspaceWebsiteInfo,
|
||||
} from '../model/workspace';
|
||||
import { parseDateRange } from '../utils/common';
|
||||
@ -188,10 +190,90 @@ workspaceRouter.get(
|
||||
};
|
||||
|
||||
const [pageviews, sessions] = await Promise.all([
|
||||
getWorkspaceWebsitePageviewStats(websiteId, filters as QueryFilters),
|
||||
getWorkspaceWebsiteSessionStats(websiteId, filters as QueryFilters),
|
||||
getWorkspaceWebsitePageview(websiteId, filters as QueryFilters),
|
||||
getWorkspaceWebsiteSession(websiteId, filters as QueryFilters),
|
||||
]);
|
||||
|
||||
res.json({ pageviews, sessions });
|
||||
}
|
||||
);
|
||||
|
||||
workspaceRouter.get(
|
||||
'/:workspaceId/website/:websiteId/stats',
|
||||
validate(
|
||||
param('workspaceId').isUUID().withMessage('workspaceId should be UUID'),
|
||||
param('websiteId').isUUID().withMessage('workspaceId should be UUID'),
|
||||
query('startAt').isNumeric().withMessage('startAt should be number'),
|
||||
query('endAt').isNumeric().withMessage('startAt should be number')
|
||||
),
|
||||
auth(),
|
||||
workspacePermission(),
|
||||
async (req, res) => {
|
||||
const workspaceId = req.params.workspaceId;
|
||||
const websiteId = req.params.websiteId;
|
||||
const {
|
||||
timezone,
|
||||
url,
|
||||
referrer,
|
||||
title,
|
||||
os,
|
||||
browser,
|
||||
device,
|
||||
country,
|
||||
region,
|
||||
city,
|
||||
startAt,
|
||||
endAt,
|
||||
} = req.query;
|
||||
|
||||
const { startDate, endDate, unit } = await parseDateRange({
|
||||
websiteId,
|
||||
startAt: Number(startAt),
|
||||
endAt: Number(endAt),
|
||||
unit: String(req.query.unit),
|
||||
});
|
||||
|
||||
const diff = dayjs(endDate).diff(startDate, 'minutes');
|
||||
const prevStartDate = dayjs(startDate).subtract(diff, 'minutes').toDate();
|
||||
const prevEndDate = dayjs(endDate).subtract(diff, 'minutes').toDate();
|
||||
|
||||
const filters = {
|
||||
startDate,
|
||||
endDate,
|
||||
timezone,
|
||||
unit,
|
||||
url,
|
||||
referrer,
|
||||
title,
|
||||
os,
|
||||
browser,
|
||||
device,
|
||||
country,
|
||||
region,
|
||||
city,
|
||||
} as QueryFilters;
|
||||
|
||||
const [metrics, prevPeriod] = await Promise.all([
|
||||
getWorkspaceWebsiteStats(websiteId, {
|
||||
...filters,
|
||||
startDate,
|
||||
endDate,
|
||||
}),
|
||||
getWorkspaceWebsiteStats(websiteId, {
|
||||
...filters,
|
||||
startDate: prevStartDate,
|
||||
endDate: prevEndDate,
|
||||
}),
|
||||
]);
|
||||
|
||||
const stats = Object.keys(metrics[0]).reduce((obj, key) => {
|
||||
obj[key] = {
|
||||
value: Number(metrics[0][key]) || 0,
|
||||
change: Number(metrics[0][key]) - Number(prevPeriod[0][key]) || 0,
|
||||
};
|
||||
return obj;
|
||||
}, {} as Record<string, { value: number; change: number }>);
|
||||
|
||||
res.json({ stats });
|
||||
}
|
||||
);
|
||||
|
@ -145,3 +145,9 @@ export function getDateQuery(
|
||||
`to_char(date_trunc('${unit}', ${field}), '${POSTGRESQL_DATE_FORMATS[unit]}')`,
|
||||
]);
|
||||
}
|
||||
|
||||
export function getTimestampIntervalQuery(field: string) {
|
||||
return Prisma.sql([
|
||||
`floor(extract(epoch from max(${field}) - min(${field})))`,
|
||||
]);
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user