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> <script>
const el = document.createElement('script'); const el = document.createElement('script');
el.src = location.origin + '/tracker.js'; 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); document.head.append(el);
</script> </script>
</body> </body>

View File

@ -1,4 +1,5 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { DateUnit } from '../../utils/date';
import { queryClient } from '../cache'; import { queryClient } from '../cache';
import { request } from '../request'; import { request } from '../request';
import { getUserTimezone } from './user'; import { getUserTimezone } from './user';
@ -126,15 +127,16 @@ export function useWorkspaceWebsitePageview(
workspaceId: string, workspaceId: string,
websiteId: string, websiteId: string,
startAt: number, startAt: number,
endAt: number endAt: number,
unit: DateUnit
) { ) {
const { data, isLoading, refetch } = useQuery( const { data, isLoading, refetch } = useQuery(
['websitePageview', { workspaceId, websiteId }], ['websitePageview', { workspaceId, websiteId, startAt, endAt }],
() => { () => {
return getWorkspaceWebsitePageview(workspaceId, websiteId, { return getWorkspaceWebsitePageview(workspaceId, websiteId, {
startAt, startAt,
endAt, endAt,
unit: 'hour', unit,
timezone: getUserTimezone(), timezone: getUserTimezone(),
}); });
} }
@ -147,3 +149,59 @@ export function useWorkspaceWebsitePageview(
refetch, 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 { DateFilter } from './DateFilter';
import { HealthBar } from './HealthBar'; import { HealthBar } from './HealthBar';
import { import {
StatsItemType,
useWorkspaceWebsitePageview, useWorkspaceWebsitePageview,
useWorkspaceWebsiteStats,
useWorspaceWebsites, useWorspaceWebsites,
WebsiteInfo, WebsiteInfo,
} from '../api/model/website'; } from '../api/model/website';
@ -18,6 +20,8 @@ import {
getDateArray, getDateArray,
} from '../utils/date'; } from '../utils/date';
import { useEvent } from '../hooks/useEvent'; import { useEvent } from '../hooks/useEvent';
import { MetricCard } from './MetricCard';
import { formatNumber, formatShortTime } from '../utils/common';
interface WebsiteOverviewProps { interface WebsiteOverviewProps {
workspaceId: string; workspaceId: string;
@ -48,16 +52,33 @@ const WebsiteOverviewItem: React.FC<{
const startDate = dayjs().subtract(1, 'day').add(1, unit).startOf(unit); const startDate = dayjs().subtract(1, 'day').add(1, unit).startOf(unit);
const endDate = dayjs().endOf(unit); const endDate = dayjs().endOf(unit);
const { pageviews, sessions, isLoading, refetch } = const {
useWorkspaceWebsitePageview( pageviews,
props.website.workspaceId, sessions,
props.website.id, isLoading: isLoadingPageview,
startDate.unix() * 1000, refetch: refetchPageview,
endDate.unix() * 1000 } = 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 () => { const handleRefresh = useEvent(async () => {
await refetch(); await Promise.all([refetchPageview(), refetchStats()]);
message.success('Refreshed'); message.success('Refreshed');
}); });
@ -71,7 +92,7 @@ const WebsiteOverviewItem: React.FC<{
]; ];
}, [pageviews, sessions, unit]); }, [pageviews, sessions, unit]);
if (isLoading) { if (isLoadingPageview || isLoadingStats) {
return <Loading />; return <Loading />;
} }
@ -98,17 +119,7 @@ const WebsiteOverviewItem: React.FC<{
</div> </div>
<div className="flex mb-10 flex-wrap"> <div className="flex mb-10 flex-wrap">
<div className="flex gap-5 flex-wrap w-full lg:w-2/3"> {stats && <MetricsBar stats={stats} />}
<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>
<div className="flex items-center gap-2 justify-end w-full lg:w-1/3"> <div className="flex items-center gap-2 justify-end w-full lg:w-1/3">
<Button <Button
@ -129,30 +140,73 @@ const WebsiteOverviewItem: React.FC<{
}); });
WebsiteOverviewItem.displayName = 'WebsiteOverviewItem'; WebsiteOverviewItem.displayName = 'WebsiteOverviewItem';
const MetricCard: React.FC<{ export const MetricsBar: React.FC<{
label: string; stats: {
value: number; bounces: StatsItemType;
diff: number; pageviews: StatsItemType;
unit?: string; totaltime: StatsItemType;
uniques: StatsItemType;
};
}> = React.memo((props) => { }> = 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 ( return (
<div className="flex flex-col justify-center min-w-[140px] min-h-[90px]"> <div className="flex gap-5 flex-wrap w-full lg:w-2/3">
<div className="flex items-center whitespace-nowrap font-bold text-4xl"> <MetricCard
{String(props.value)} label="Views"
{unit} value={pageviews.value}
</div> change={pageviews.change}
<div className="flex items-center whitespace-nowrap font-bold"> />
<span className="mr-2">{props.label}</span> <MetricCard
<Tag color={props.diff >= 0 ? 'green' : 'red'}> label="Visitors"
{props.diff >= 0 ? `+${props.diff}${unit}` : `${props.diff}${unit}`} value={uniques.value}
</Tag> change={uniques.change}
</div> />
<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> </div>
); );
}); });
MetricCard.displayName = 'MetricCard'; MetricsBar.displayName = 'MetricsBar';
export const StatsChart: React.FC<{ export const StatsChart: React.FC<{
data: { x: string; y: number; type: string }[]; 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 { 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'; import { DEFAULT_RESET_DATE, EVENT_TYPE } from '../utils/const';
export async function getWorkspaceUser(workspaceId: string, userId: string) { 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, websiteId: string,
filters: QueryFilters filters: QueryFilters
) { ) {
@ -162,7 +167,7 @@ export async function getWorkspaceWebsitePageviewStats(
`; `;
} }
export async function getWorkspaceWebsiteSessionStats( export async function getWorkspaceWebsiteSession(
websiteId: string, websiteId: string,
filters: QueryFilters filters: QueryFilters
) { ) {
@ -186,3 +191,38 @@ export async function getWorkspaceWebsiteSessionStats(
group by 1 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 { Router } from 'express';
import { auth } from '../middleware/auth'; import { auth } from '../middleware/auth';
import { body, param, query, validate } from '../middleware/validate'; import { body, param, query, validate } from '../middleware/validate';
@ -6,9 +7,10 @@ import {
addWorkspaceWebsite, addWorkspaceWebsite,
deleteWorkspaceWebsite, deleteWorkspaceWebsite,
getWorkspaceWebsiteInfo, getWorkspaceWebsiteInfo,
getWorkspaceWebsitePageviewStats, getWorkspaceWebsitePageview,
getWorkspaceWebsites, getWorkspaceWebsites,
getWorkspaceWebsiteSessionStats, getWorkspaceWebsiteSession,
getWorkspaceWebsiteStats,
updateWorkspaceWebsiteInfo, updateWorkspaceWebsiteInfo,
} from '../model/workspace'; } from '../model/workspace';
import { parseDateRange } from '../utils/common'; import { parseDateRange } from '../utils/common';
@ -188,10 +190,90 @@ workspaceRouter.get(
}; };
const [pageviews, sessions] = await Promise.all([ const [pageviews, sessions] = await Promise.all([
getWorkspaceWebsitePageviewStats(websiteId, filters as QueryFilters), getWorkspaceWebsitePageview(websiteId, filters as QueryFilters),
getWorkspaceWebsiteSessionStats(websiteId, filters as QueryFilters), getWorkspaceWebsiteSession(websiteId, filters as QueryFilters),
]); ]);
res.json({ pageviews, sessions }); 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]}')`, `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})))`,
]);
}