feat: add metric bar data
This commit is contained in:
parent
31031dc56b
commit
7ccbb922de
@ -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>
|
||||||
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
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 { 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 }[];
|
||||||
|
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 { 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
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
@ -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 });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
@ -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})))`,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user