feat: add metric bar data

This commit is contained in:
moonrailgun 2023-09-19 19:29:28 +08:00
parent 31031dc56b
commit 7ccbb922de
8 changed files with 365 additions and 49 deletions

View File

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

View File

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

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

View File

@ -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(
const {
pageviews,
sessions,
isLoading: isLoadingPageview,
refetch: refetchPageview,
} = useWorkspaceWebsitePageview(
props.website.workspaceId,
props.website.id,
startDate.unix() * 1000,
endDate.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 }[];

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

View File

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

View File

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

View File

@ -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})))`,
]);
}