Update realtime chart.

This commit is contained in:
Mike Cao 2020-10-08 23:26:05 -07:00
parent e64a555652
commit fdc92d087b
32 changed files with 240 additions and 58 deletions

View File

@ -1,8 +1,8 @@
import React, { useMemo } from 'react';
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import useFetch from 'hooks/useFetch';
import styles from './ActiveUsers.module.css';
import { FormattedMessage } from 'react-intl';
export default function ActiveUsers({ websiteId, token, className }) {
const { data } = useFetch(`/api/website/${websiteId}/active`, { token }, { interval: 60000 });

View File

@ -39,6 +39,8 @@ export default function BarChart({
const w = canvas.current.width;
switch (unit) {
case 'minute':
return dateFormat(d, 'h:mm', locale);
case 'hour':
return dateFormat(d, 'ha', locale);
case 'day':

View File

@ -26,7 +26,7 @@ export default function PageviewsChart({ websiteId, data, unit, records, classNa
data: { datasets },
} = chart;
datasets[0].data = data.uniques;
datasets[0].data = data.sessions;
datasets[0].label = intl.formatMessage({
id: 'metrics.unique-visitors',
defaultMessage: 'Unique visitors',
@ -56,7 +56,7 @@ export default function PageviewsChart({ websiteId, data, unit, records, classNa
id: 'metrics.unique-visitors',
defaultMessage: 'Unique visitors',
}),
data: data.uniques,
data: data.sessions,
lineTension: 0,
backgroundColor: colors.visitors.background,
borderColor: colors.visitors.border,

View File

@ -45,14 +45,14 @@ export default function WebsiteChart({
{ onDataLoad, update: [modified] },
);
const [pageviews, uniques] = useMemo(() => {
const chartData = useMemo(() => {
if (data) {
return [
getDateArray(data.pageviews, startDate, endDate, unit),
getDateArray(data.uniques, startDate, endDate, unit),
];
return {
pageviews: getDateArray(data.pageviews, startDate, endDate, unit),
sessions: getDateArray(data.sessions, startDate, endDate, unit),
};
}
return [[], []];
return { pageviews: [], sessions: [] };
}, [data]);
function handleCloseFilter() {
@ -87,7 +87,7 @@ export default function WebsiteChart({
{error && <ErrorMessage />}
<PageviewsChart
websiteId={websiteId}
data={{ pageviews, uniques }}
data={chartData}
unit={unit}
records={getDateLength(startDate, endDate, unit)}
loading={loading}

View File

@ -1,36 +1,101 @@
import React, { useState } from 'react';
import { useSelector } from 'react-redux';
import Page from '../layout/Page';
import PageHeader from '../layout/PageHeader';
import useFetch from '../../hooks/useFetch';
import DropDown from '../common/DropDown';
import RealtimeChart from '../metrics/RealtimeChart';
import React, { useState, useEffect, useMemo } from 'react';
import { FormattedMessage } from 'react-intl';
import { subMinutes, startOfMinute, parseISO, format } from 'date-fns';
import Page from 'components/layout/Page';
import PageHeader from 'components/layout/PageHeader';
import DropDown from 'components/common/DropDown';
import useFetch from 'hooks/useFetch';
import PageviewsChart from '../metrics/PageviewsChart';
import { getDateArray } from '../../lib/date';
export default function TestConsole() {
const user = useSelector(state => state.user);
function filterTime(data, time) {
return data.filter(({ created_at }) => new Date(created_at).getTime() > time);
}
function mapData(data) {
let last = 0;
const arr = [];
data.reduce((obj, val) => {
const { created_at } = val;
const t = startOfMinute(parseISO(created_at));
if (t.getTime() > last) {
obj = { t: format(t, 'yyyy-LL-dd HH:mm:00'), y: 1 };
arr.push(obj);
last = t;
} else {
obj.y += 1;
}
return obj;
}, {});
return arr;
}
export default function RealtimeDashboard() {
const [data, setData] = useState();
const [website, setWebsite] = useState();
const { data } = useFetch('/api/websites');
const { data: init, loading } = useFetch('/api/realtime', { type: 'init' });
const { data: updates } = useFetch(
'/api/realtime',
{ type: 'update' },
{ disabled: !init?.token, interval: 5000, headers: { 'x-umami-token': init?.token } },
);
if (!data || !user?.is_admin) {
const chartData = useMemo(() => {
if (data) {
const endDate = startOfMinute(new Date());
const startDate = subMinutes(endDate, 30);
const unit = 'minute';
console.log({ data });
return {
pageviews: getDateArray(mapData(data.pageviews), startDate, endDate, unit),
sessions: getDateArray(mapData(data.sessions), startDate, endDate, unit),
};
}
return { pageviews: [], sessions: [] };
}, [data]);
useEffect(() => {
if (init && !data) {
setData(init.data);
} else if (updates) {
const { pageviews, sessions, events } = updates;
const time = subMinutes(startOfMinute(new Date()), 30).getTime();
setData(state => ({
pageviews: filterTime(state.pageviews, time).concat(pageviews),
sessions: filterTime(state.sessions, time).concat(sessions),
events: filterTime(state.events, time).concat(events),
}));
}
}, [updates, init]);
if (!init || loading || !data) {
return null;
}
const options = [{ label: 'All websites', value: 0 }].concat(
data.map(({ name, website_id }) => ({ label: name, value: website_id })),
);
const { websites } = init;
const options = [
{ label: <FormattedMessage id="label.all-websites" defaultMessage="All websites" />, value: 0 },
].concat(websites.map(({ name, website_id }) => ({ label: name, value: website_id })));
const selectedValue = options.find(({ value }) => value === website?.website_id)?.value || 0;
function handleSelect(value) {
setWebsite(data.find(({ website_id }) => website_id === value));
setWebsite(websites.find(({ website_id }) => website_id === value));
}
return (
<Page>
<PageHeader>
<div>Real time</div>
<div>
<FormattedMessage id="label.realtime" defaultMessage="Realtime" />
</div>
<DropDown value={selectedValue} options={options} onChange={handleSelect} />
</PageHeader>
<RealtimeChart websiteId={website?.website_id} />
<PageviewsChart websiteId={website?.website_id} data={chartData} unit="minute" records={30} />
</Page>
);
}

View File

@ -14,14 +14,14 @@ export default function useFetch(url, params = {}, options = {}) {
const keys = Object.keys(params)
.sort()
.map(key => params[key]);
const { update = [], onDataLoad = () => {} } = options;
const { update = [], onDataLoad = () => {}, disabled, headers } = options;
async function loadData() {
try {
setLoadiing(true);
setError(null);
const time = performance.now();
const { data, status } = await get(`${basePath}${url}`, params);
const { data, status } = await get(`${basePath}${url}`, params, headers);
dispatch(updateQuery({ url, time: performance.now() - time, completed: Date.now() }));
@ -43,7 +43,7 @@ export default function useFetch(url, params = {}, options = {}) {
}
useEffect(() => {
if (url) {
if (url && !disabled) {
const { interval, delay = 0 } = options;
setTimeout(() => loadData(), delay);
@ -54,7 +54,7 @@ export default function useFetch(url, params = {}, options = {}) {
clearInterval(id);
};
}
}, [url, ...keys, ...update]);
}, [url, disabled, ...keys, ...update]);
return { data, status, error, loading };
}

View File

@ -18,6 +18,7 @@
"button.view-details": "Vis detajler",
"label.accounts": "Kontoer",
"label.administrator": "Administrator",
"label.all-websites": "All websites",
"label.confirm-password": "Godkendt adgangskode",
"label.current-password": "Nuværende adgangskode",
"label.custom-range": "Tilpasset interval",
@ -36,6 +37,7 @@
"label.password": "Adgangskode",
"label.passwords-dont-match": "Adgangskoder matcher ikke",
"label.profile": "Profil",
"label.realtime": "Realtime",
"label.required": "Påkrævet",
"label.settings": "Indstillinger",
"label.this-month": "Denne måned",

View File

@ -18,6 +18,7 @@
"button.view-details": "Details anzeigen",
"label.accounts": "Konten",
"label.administrator": "Administrator",
"label.all-websites": "All websites",
"label.confirm-password": "Passwort wiederholen",
"label.current-password": "Derzeitiges Passwort",
"label.custom-range": "Benutzerdefinierter Bereich",
@ -36,6 +37,7 @@
"label.password": "Passwort",
"label.passwords-dont-match": "Passwörter stimmen nicht überein",
"label.profile": "Profil",
"label.realtime": "Realtime",
"label.required": "Erforderlich",
"label.settings": "Einstellungen",
"label.this-month": "Diesen Monat",

View File

@ -18,6 +18,7 @@
"button.view-details": "Λεπτομέρειες",
"label.accounts": "Λογαριασμοί",
"label.administrator": "Διαχειριστής",
"label.all-websites": "All websites",
"label.confirm-password": "Επιβεβαίωση κωδικού",
"label.current-password": "Τωρινός κωδικός πρόσβασης",
"label.custom-range": "Προσαρμοσμένο εύρος",
@ -36,6 +37,7 @@
"label.password": "Κωδικός",
"label.passwords-dont-match": "Οι κωδικοί πρόσβασης δεν ταιριάζουν",
"label.profile": "Προφίλ",
"label.realtime": "Realtime",
"label.required": "Απαιτείται",
"label.settings": "Ρυθμίσεις",
"label.this-month": "Αυτο το μήνα",

View File

@ -18,6 +18,7 @@
"button.view-details": "View details",
"label.accounts": "Accounts",
"label.administrator": "Administrator",
"label.all-websites": "All websites",
"label.confirm-password": "Confirm password",
"label.current-password": "Current password",
"label.custom-range": "Custom range",
@ -36,6 +37,7 @@
"label.password": "Password",
"label.passwords-dont-match": "Passwords don't match",
"label.profile": "Profile",
"label.realtime": "Realtime",
"label.required": "Required",
"label.settings": "Settings",
"label.this-month": "This month",

View File

@ -18,6 +18,7 @@
"button.view-details": "Ver detalles",
"label.accounts": "Usuarios",
"label.administrator": "Administrador",
"label.all-websites": "All websites",
"label.confirm-password": "Confirmar contraseña",
"label.current-password": "Contraseña actual",
"label.custom-range": "Custom range",
@ -36,6 +37,7 @@
"label.password": "Contraseña",
"label.passwords-dont-match": "Las contraseñas no coinciden",
"label.profile": "Perfil",
"label.realtime": "Realtime",
"label.required": "Requerido",
"label.settings": "Configuraciones",
"label.this-month": "Este mes",

View File

@ -18,6 +18,7 @@
"button.view-details": "Vís upplýsingar",
"label.accounts": "Brúkarar",
"label.administrator": "Administrator",
"label.all-websites": "All websites",
"label.confirm-password": "Vátta loyniorð",
"label.current-password": "Núverandi loyniorð",
"label.custom-range": "Tillaga spenni",
@ -36,6 +37,7 @@
"label.password": "Loyniorð",
"label.passwords-dont-match": "Loyniorðini eru ikki eins",
"label.profile": "Brúkari",
"label.realtime": "Realtime",
"label.required": "Krav",
"label.settings": "Stillingar",
"label.this-month": "Hendan mánan",

View File

@ -18,6 +18,7 @@
"button.view-details": "Voir les details",
"label.accounts": "Comptes",
"label.administrator": "Administrateur",
"label.all-websites": "All websites",
"label.confirm-password": "Confirmation du mot de passe",
"label.current-password": "Mot de passe actuel",
"label.custom-range": "Plage personnalisée",
@ -36,6 +37,7 @@
"label.password": "Mot de passe",
"label.passwords-dont-match": "Les mots de passe ne correspondent pas",
"label.profile": "Profile",
"label.realtime": "Realtime",
"label.required": "Requis",
"label.settings": "Paramètres",
"label.this-month": "Ce mois ci",

View File

@ -18,6 +18,7 @@
"button.view-details": "Lihat Detil",
"label.accounts": "Akun",
"label.administrator": "Pengelola",
"label.all-websites": "All websites",
"label.confirm-password": "Konfirmasi kata sandi",
"label.current-password": "Kata sandi sekarang",
"label.custom-range": "Rentang khusus",
@ -36,6 +37,7 @@
"label.password": "Kata sandi",
"label.passwords-dont-match": "Kata sandi tidak cocok",
"label.profile": "Profil",
"label.realtime": "Realtime",
"label.required": "Wajib",
"label.settings": "Pengaturan",
"label.this-month": "Bulan ini",

View File

@ -18,6 +18,7 @@
"button.view-details": "詳細を見る",
"label.accounts": "アカウント",
"label.administrator": "管理者",
"label.all-websites": "All websites",
"label.confirm-password": "パスワード(確認)",
"label.current-password": "現在のパスワード",
"label.custom-range": "期間を指定する",
@ -36,6 +37,7 @@
"label.password": "パスワード",
"label.passwords-dont-match": "パスワードが一致しません",
"label.profile": "プロファイル",
"label.realtime": "Realtime",
"label.required": "必須",
"label.settings": "設定",
"label.this-month": "今月",

View File

@ -18,6 +18,7 @@
"button.view-details": "Дэлгэрүүлж харах",
"label.accounts": "Хэрэглэгчид",
"label.administrator": "Админ",
"label.all-websites": "All websites",
"label.confirm-password": "Шинэ нууц үгээ давтах",
"label.current-password": "Ашиглаж буй нууц үг",
"label.custom-range": "Дурын хугацаа",
@ -36,6 +37,7 @@
"label.password": "Нууц үг",
"label.passwords-dont-match": "Нууц үг тохирохгүй байна",
"label.profile": "Бүртгэл",
"label.realtime": "Realtime",
"label.required": "Шаардлагатай",
"label.settings": "Тохиргоо",
"label.this-month": "Энэ сар",

View File

@ -18,6 +18,7 @@
"button.view-details": "Vis detaljer",
"label.accounts": "Kontoer",
"label.administrator": "Administrator",
"label.all-websites": "All websites",
"label.confirm-password": "Godkjenn passord",
"label.current-password": "Nåværende passord",
"label.custom-range": "Egendefinert utvalg",
@ -36,6 +37,7 @@
"label.password": "Passord",
"label.passwords-dont-match": "Passordene er ikke like",
"label.profile": "Profil",
"label.realtime": "Realtime",
"label.required": "Påkrevd",
"label.settings": "Innstillinger",
"label.this-month": "Denne måneden",

View File

@ -18,6 +18,7 @@
"button.view-details": "Meer details",
"label.accounts": "Gebruikers",
"label.administrator": "Administrator",
"label.all-websites": "All websites",
"label.confirm-password": "Wachtwoord bevestigen",
"label.current-password": "Huidig wachtwoord",
"label.custom-range": "Aangepast bereik",
@ -36,6 +37,7 @@
"label.password": "Wachtwoord",
"label.passwords-dont-match": "Wachtwoorden komen niet overeen",
"label.profile": "Profiel",
"label.realtime": "Realtime",
"label.required": "Verplicht",
"label.settings": "Instellingen",
"label.this-month": "Deze maand",

View File

@ -18,6 +18,7 @@
"button.view-details": "Ver detalhes",
"label.accounts": "Contas",
"label.administrator": "Administrador",
"label.all-websites": "All websites",
"label.confirm-password": "Confirmar palavra-passe",
"label.current-password": "Palavra-passe atual",
"label.custom-range": "Intervalo personalizado",
@ -36,6 +37,7 @@
"label.password": "Palavra-passe",
"label.passwords-dont-match": "Palavra-passes não correspondem",
"label.profile": "Perfil",
"label.realtime": "Realtime",
"label.required": "Obrigatório",
"label.settings": "Definições",
"label.this-month": "Este mês",

View File

@ -18,6 +18,7 @@
"button.view-details": "Vizualizare detalii",
"label.accounts": "Conturi",
"label.administrator": "Administrator",
"label.all-websites": "All websites",
"label.confirm-password": "Confirmare parolă",
"label.current-password": "Parola curentă",
"label.custom-range": "Interval personalizat",
@ -36,6 +37,7 @@
"label.password": "Parolă",
"label.passwords-dont-match": "Parolele nu se potrivesc",
"label.profile": "Profil",
"label.realtime": "Realtime",
"label.required": "Obligatoriu",
"label.settings": "Setări",
"label.this-month": "Această lună",

View File

@ -18,6 +18,7 @@
"button.view-details": "Посмотреть детали",
"label.accounts": "Аккаунты",
"label.administrator": "Администратор",
"label.all-websites": "All websites",
"label.confirm-password": "Подтвердить пароль",
"label.current-password": "Текущий пароль",
"label.custom-range": "Другой период",
@ -36,6 +37,7 @@
"label.password": "Пароль",
"label.passwords-dont-match": "Пароли не совпадают",
"label.profile": "Профиль",
"label.realtime": "Realtime",
"label.required": "Обязательное",
"label.settings": "Настройки",
"label.this-month": "Этот месяц",

View File

@ -18,6 +18,7 @@
"button.view-details": "Visa detaljer",
"label.accounts": "Konton",
"label.administrator": "Administratör",
"label.all-websites": "All websites",
"label.confirm-password": "Bekräfta lösenord",
"label.current-password": "Nuvarande lösenord",
"label.custom-range": "Anpassat urval",
@ -36,6 +37,7 @@
"label.password": "Lösenord",
"label.passwords-dont-match": "Lösenorden är inte samma",
"label.profile": "Profil",
"label.realtime": "Realtime",
"label.required": "Krävs",
"label.settings": "Inställningar",
"label.this-month": "Denna månad",

View File

@ -18,6 +18,7 @@
"button.view-details": "Detayı incele",
"label.accounts": "Hesaplar",
"label.administrator": "Yönetici",
"label.all-websites": "All websites",
"label.confirm-password": "Parolayı onayla",
"label.current-password": "Mevcut parola",
"label.custom-range": "Özelleştirilmiş aralık",
@ -36,6 +37,7 @@
"label.password": "Parola",
"label.passwords-dont-match": "Parolalar uyuşmuyor",
"label.profile": "Profil",
"label.realtime": "Realtime",
"label.required": "Zorunlu alan",
"label.settings": "Ayarlar",
"label.this-month": "Bu ay",

View File

@ -18,6 +18,7 @@
"button.view-details": "Переглянути деталі",
"label.accounts": "Облікові записи",
"label.administrator": "Адміністратор",
"label.all-websites": "All websites",
"label.confirm-password": "Підтвердити пароль",
"label.current-password": "Поточний пароль",
"label.custom-range": "Довільний період",
@ -36,6 +37,7 @@
"label.password": "Пароль",
"label.passwords-dont-match": "Паролі не співпадають",
"label.profile": "Профіль",
"label.realtime": "Realtime",
"label.required": "Обов'язкове",
"label.settings": "Налаштування",
"label.this-month": "Поточний місяць",

View File

@ -18,6 +18,7 @@
"button.view-details": "查看更多",
"label.accounts": "账户",
"label.administrator": "管理员",
"label.all-websites": "All websites",
"label.confirm-password": "确认密码",
"label.current-password": "目前密码",
"label.custom-range": "自定义时间段",
@ -36,6 +37,7 @@
"label.password": "密码",
"label.passwords-dont-match": "密码不一致",
"label.profile": "个人资料",
"label.realtime": "Realtime",
"label.required": "必填",
"label.settings": "设置",
"label.this-month": "本月",

View File

@ -7,6 +7,7 @@ import {
addYears,
subHours,
subDays,
startOfMinute,
startOfHour,
startOfDay,
startOfWeek,
@ -17,6 +18,7 @@ import {
endOfWeek,
endOfMonth,
endOfYear,
differenceInMinutes,
differenceInHours,
differenceInCalendarDays,
differenceInCalendarMonths,
@ -114,6 +116,7 @@ export function getDateFromString(str) {
}
const dateFuncs = {
minute: [differenceInMinutes, addMinutes, startOfMinute],
hour: [differenceInHours, addHours, startOfHour],
day: [differenceInCalendarDays, addDays, startOfDay],
month: [differenceInCalendarMonths, addMonths, startOfMonth],

View File

@ -166,16 +166,6 @@ export async function createSession(website_id, data) {
);
}
export async function getSessionById(session_id) {
return runQuery(
prisma.session.findOne({
where: {
session_id,
},
}),
);
}
export async function getSessionByUuid(session_uuid) {
return runQuery(
prisma.session.findOne({
@ -285,6 +275,57 @@ export async function createAccount(data) {
);
}
export async function getSessions(websites, start_at) {
return runQuery(
prisma.session.findMany({
where: {
website: {
website_id: {
in: websites,
},
},
created_at: {
gte: start_at,
},
},
}),
);
}
export async function getPageviews(websites, start_at) {
return runQuery(
prisma.pageview.findMany({
where: {
website: {
website_id: {
in: websites,
},
},
created_at: {
gte: start_at,
},
},
}),
);
}
export async function getEvents(websites, start_at) {
return runQuery(
prisma.event.findMany({
where: {
website: {
website_id: {
in: websites,
},
},
created_at: {
gte: start_at,
},
},
}),
);
}
export function getWebsiteStats(website_id, start_at, end_at, filters = {}) {
const params = [website_id, start_at, end_at];
const { url } = filters;
@ -425,7 +466,7 @@ export function getActiveVisitors(website_id) {
);
}
export function getEvents(
export function getEventMetrics(
website_id,
start_at,
end_at,

View File

@ -19,13 +19,17 @@ export const apiRequest = (method, url, body, headers) =>
return res.text().then(data => ({ ok: res.ok, status: res.status, res: res, data }));
});
export const get = (url, params) => apiRequest('get', `${url}${getQueryString(params)}`);
export const get = (url, params, headers) =>
apiRequest('get', `${url}${getQueryString(params)}`, undefined, headers);
export const del = (url, params) => apiRequest('delete', `${url}${getQueryString(params)}`);
export const del = (url, params, headers) =>
apiRequest('delete', `${url}${getQueryString(params)}`, undefined, headers);
export const post = (url, params) => apiRequest('post', url, JSON.stringify(params));
export const post = (url, params, headers) =>
apiRequest('post', url, JSON.stringify(params), headers);
export const put = (url, params) => apiRequest('put', url, JSON.stringify(params));
export const put = (url, params, headers) =>
apiRequest('put', url, JSON.stringify(params), headers);
export const hook = (_this, method, callback) => {
const orig = _this[method];

View File

@ -1,6 +1,6 @@
{
"name": "umami",
"version": "0.81.0",
"version": "0.82.0",
"description": "A simple, fast, website analytics alternative to Google Analytics. ",
"author": "Mike Cao <mike@mikecao.com>",
"license": "MIT",

View File

@ -1,19 +1,48 @@
import { subMinutes } from 'date-fns';
import { useAuth } from 'lib/middleware';
import { ok, unauthorized, methodNotAllowed } from 'lib/response';
import { ok, methodNotAllowed, badRequest } from 'lib/response';
import { getEvents, getPageviews, getSessions, getUserWebsites } from 'lib/queries';
import { createToken, parseToken } from 'lib/crypto';
export default async (req, res) => {
await useAuth(req, res);
const { is_admin } = req.auth;
if (!is_admin) {
return unauthorized(res);
async function getData(websites, time) {
return Promise.all([
getPageviews(websites, time),
getSessions(websites, time),
getEvents(websites, time),
]);
}
if (req.method === 'GET') {
const [pageviews, sessions, events] = await Promise.all([[], [], []]);
const { type } = req.query;
const { user_id } = req.auth;
return ok(res, { pageviews, sessions, events });
if (type === 'init') {
const websites = await getUserWebsites(user_id);
const ids = websites.map(({ website_id }) => website_id);
const [pageviews, sessions, events] = await getData(ids, subMinutes(new Date(), 30));
const token = await createToken({ websites: ids });
return ok(res, { websites, token, data: { pageviews, sessions, events } });
}
if (type === 'update') {
const token = req.headers['x-umami-token'];
if (!token) {
return badRequest(res);
}
const { websites } = await parseToken(token);
const [pageviews, sessions, events] = await getData(websites, new Date());
return ok(res, { pageviews, sessions, events });
}
return badRequest(res);
}
return methodNotAllowed(res);

View File

@ -1,5 +1,5 @@
import moment from 'moment-timezone';
import { getEvents } from 'lib/queries';
import { getEventMetrics } from 'lib/queries';
import { ok, badRequest, methodNotAllowed, unauthorized } from 'lib/response';
import { allowQuery } from 'lib/auth';
@ -21,7 +21,7 @@ export default async (req, res) => {
const startDate = new Date(+start_at);
const endDate = new Date(+end_at);
const events = await getEvents(websiteId, startDate, endDate, tz, unit, { url });
const events = await getEventMetrics(websiteId, startDate, endDate, tz, unit, { url });
return ok(res, events);
}

View File

@ -21,12 +21,12 @@ export default async (req, res) => {
return badRequest(res);
}
const [pageviews, uniques] = await Promise.all([
const [pageviews, sessions] = await Promise.all([
getPageviewStats(websiteId, startDate, endDate, tz, unit, '*', url),
getPageviewStats(websiteId, startDate, endDate, tz, unit, 'distinct session_id', url),
]);
return ok(res, { pageviews, uniques });
return ok(res, { pageviews, sessions });
}
return methodNotAllowed(res);