Display page views and unique visitors.

This commit is contained in:
Mike Cao 2020-07-28 19:04:45 -07:00
parent bdcdcd9d13
commit ce92c7897d
16 changed files with 162 additions and 44 deletions

View File

@ -15,7 +15,9 @@ export default function DateFilter({ onChange }) {
return ( return (
<select value={selected} onChange={handleChange}> <select value={selected} onChange={handleChange}>
{filterOptions.map(option => ( {filterOptions.map(option => (
<option name={option}>{option}</option> <option key={option} name={option}>
{option}
</option>
))} ))}
</select> </select>
); );

11
components/MetricCard.js Normal file
View File

@ -0,0 +1,11 @@
import React from 'react';
import styles from './MetricCard.module.css';
const MetricCard = ({ value, label }) => (
<div className={styles.card}>
<div className={styles.value}>{value}</div>
<div className={styles.label}>{label}</div>
</div>
);
export default MetricCard;

View File

@ -0,0 +1,16 @@
.card {
display: flex;
flex-direction: column;
justify-content: center;
margin-right: 50px;
}
.value {
font-size: 36px;
line-height: 40px;
font-weight: 600;
}
.label {
font-size: 16px;
}

View File

@ -1,16 +1,9 @@
import React, { useRef, useEffect, useMemo } from 'react'; import React, { useRef, useEffect } from 'react';
import ChartJS from 'chart.js'; import ChartJS from 'chart.js';
import { getLocalTime } from 'lib/date';
export default function PageviewsChart({ data }) { export default function PageviewsChart({ data }) {
const canvas = useRef(); const canvas = useRef();
const chart = useRef(); const chart = useRef();
const pageviews = useMemo(() => {
if (data) {
return data.pageviews.map(({ t, y }) => ({ t: getLocalTime(t), y }));
}
return [];
}, [data]);
function draw() { function draw() {
if (!canvas.current) return; if (!canvas.current) return;
@ -21,11 +14,19 @@ export default function PageviewsChart({ data }) {
data: { data: {
datasets: [ datasets: [
{ {
label: 'page views', label: 'unique visitors',
data: pageviews, data: data.uniques,
lineTension: 0, lineTension: 0,
backgroundColor: 'rgb(38, 128, 235, 0.1)', backgroundColor: 'rgb(146, 86, 217, 0.2)',
borderColor: 'rgb(13, 102, 208, 0.2)', borderColor: 'rgb(122, 66, 191, 0.3)',
borderWidth: 1,
},
{
label: 'page views',
data: data.pageviews,
lineTension: 0,
backgroundColor: 'rgb(38, 128, 235, 0.2)',
borderColor: 'rgb(13, 102, 208, 0.3)',
borderWidth: 1, borderWidth: 1,
}, },
], ],
@ -52,6 +53,10 @@ export default function PageviewsChart({ data }) {
}, },
tooltipFormat: 'ddd M/DD hA', tooltipFormat: 'ddd M/DD hA',
}, },
gridLines: {
display: false,
},
stacked: true,
}, },
], ],
yAxes: [ yAxes: [
@ -59,13 +64,15 @@ export default function PageviewsChart({ data }) {
ticks: { ticks: {
beginAtZero: true, beginAtZero: true,
}, },
stacked: true,
}, },
], ],
}, },
}, },
}); });
} else { } else {
chart.current.data.datasets[0].data = pageviews; chart.current.data.datasets[0].data = data.uniques;
chart.current.data.datasets[1].data = data.pageviews;
chart.current.update(); chart.current.update();
} }
} }

View File

@ -26,7 +26,7 @@ export default function WebsiteList() {
<DateFilter onChange={handleDateChange} /> <DateFilter onChange={handleDateChange} />
{data && {data &&
data.websites.map(({ website_id, label }) => ( data.websites.map(({ website_id, label }) => (
<div> <div key={website_id}>
<h2>{label}</h2> <h2>{label}</h2>
<WebsiteStats <WebsiteStats
websiteId={website_id} websiteId={website_id}

View File

@ -1,10 +1,20 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useMemo } from 'react';
import PageviewsChart from './PageviewsChart'; import PageviewsChart from './PageviewsChart';
import { get } from 'lib/web'; import { get } from 'lib/web';
import { getTimezone } from 'lib/date'; import { getDateArray, getTimezone } from 'lib/date';
import WebsiteSummary from './WebsiteSummary';
export default function WebsiteStats({ websiteId, startDate, endDate, unit }) { export default function WebsiteStats({ websiteId, startDate, endDate, unit }) {
const [data, setData] = useState(); const [data, setData] = useState();
const [pageviews, uniques] = useMemo(() => {
if (data) {
return [
getDateArray(data.pageviews, startDate, endDate, unit),
getDateArray(data.uniques, startDate, endDate, unit),
];
}
return [[], []];
}, [data]);
async function loadData() { async function loadData() {
setData( setData(
@ -19,7 +29,12 @@ export default function WebsiteStats({ websiteId, startDate, endDate, unit }) {
useEffect(() => { useEffect(() => {
loadData(); loadData();
}, [websiteId, startDate, endDate]); }, [websiteId, startDate, endDate, unit]);
return <PageviewsChart data={data} />; return (
<div>
<WebsiteSummary data={{ pageviews, uniques }} />
<PageviewsChart data={{ pageviews, uniques }} />
</div>
);
} }

View File

@ -0,0 +1,16 @@
import React from 'react';
import MetricCard from './MetricCard';
import styles from './WebsiteSummary.module.css';
function getTotal(data) {
return data.reduce((n, v) => n + v.y, 0);
}
export default function WebsiteSummary({ data }) {
return (
<div className={styles.container}>
<MetricCard label="Views" value={getTotal(data.pageviews)} />
<MetricCard label="Visitors" value={getTotal(data.uniques)} />
</div>
);
}

View File

@ -0,0 +1,3 @@
.container {
display: flex;
}

View File

@ -1,9 +1,24 @@
import moment from 'moment-timezone'; import moment from 'moment-timezone';
import { addMinutes, endOfDay, subDays, subHours } from 'date-fns'; import {
addMinutes,
addHours,
startOfDay,
endOfHour,
endOfDay,
startOfHour,
addDays,
subDays,
subHours,
differenceInHours,
differenceInDays,
} from 'date-fns';
export function getTimezone() { export function getTimezone() {
const tz = moment.tz.guess(); return moment.tz.guess();
return moment.tz.zone(tz).abbr(new Date().getTimezoneOffset()); }
export function getTimezonAbbr() {
return moment.tz.zone(getTimezone()).abbr(new Date().getTimezoneOffset());
} }
export function getLocalTime(t) { export function getLocalTime(t) {
@ -12,26 +27,51 @@ export function getLocalTime(t) {
export function getDateRange(value) { export function getDateRange(value) {
const now = new Date(); const now = new Date();
const endToday = endOfDay(now); const hour = endOfHour(now);
const day = endOfDay(now);
switch (value) { switch (value) {
case '7d': case '7d':
return { return {
startDate: subDays(endToday, 7), startDate: subDays(day, 7),
endDate: endToday, endDate: day,
unit: 'day', unit: 'day',
}; };
case '30d': case '30d':
return { return {
startDate: subDays(endToday, 30), startDate: subDays(day, 30),
endDate: endToday, endDate: day,
unit: 'day', unit: 'day',
}; };
default: default:
return { return {
startDate: subHours(now, 24), startDate: subHours(hour, 24),
endDate: now, endDate: hour,
unit: 'hour', unit: 'hour',
}; };
} }
} }
const dateFuncs = {
hour: [differenceInHours, addHours, startOfHour],
day: [differenceInDays, addDays, startOfDay],
};
export function getDateArray(data, startDate, endDate, unit) {
const arr = [];
const [diff, add, normalize] = dateFuncs[unit];
const n = diff(endDate, startDate);
function findData(t) {
return data.find(e => getLocalTime(e.t).getTime() === normalize(t).getTime())?.y || 0;
}
for (let i = 0; i < n; i++) {
const t = add(startDate, i + 1);
const y = findData(t);
arr.push({ t, y });
}
return arr;
}

View File

@ -1,4 +1,5 @@
import { PrismaClient } from '@prisma/client'; import { PrismaClient } from '@prisma/client';
import chalk from 'chalk';
const options = { const options = {
log: [ log: [
@ -9,13 +10,21 @@ const options = {
], ],
}; };
function logQuery(e) {
if (process.env.LOG_QUERY) {
console.log(chalk.yellow(e.params), '->', e.query, chalk.greenBright(`${e.duration}ms`));
}
}
let prisma; let prisma;
if (process.env.NODE_ENV === 'production') { if (process.env.NODE_ENV === 'production') {
prisma = new PrismaClient(options); prisma = new PrismaClient(options);
prisma.on('query', logQuery);
} else { } else {
if (!global.prisma) { if (!global.prisma) {
global.prisma = new PrismaClient(options); global.prisma = new PrismaClient(options);
global.prisma.on('query', logQuery);
} }
prisma = global.prisma; prisma = global.prisma;
@ -23,12 +32,6 @@ if (process.env.NODE_ENV === 'production') {
export default prisma; export default prisma;
prisma.on('query', e => {
if (process.env.LOG_QUERY) {
console.log(`${e.params} -> ${e.query} (${e.duration}ms)`);
}
});
export async function runQuery(query) { export async function runQuery(query) {
return query.catch(e => { return query.catch(e => {
console.error(e); console.error(e);

View File

@ -18,8 +18,8 @@ export default async (req, res) => {
res.setHeader('Set-Cookie', [cookie]); res.setHeader('Set-Cookie', [cookie]);
res.status(200).json({ token }); return res.status(200).json({ token });
} else {
res.status(401).end();
} }
return res.status(401).end();
}; };

View File

@ -26,5 +26,5 @@ export default async (req, res) => {
ok = true; ok = true;
} }
res.status(200).json({ ok, session: token }); return res.status(200).json({ ok, session: token });
}; };

View File

@ -5,8 +5,8 @@ export default async (req, res) => {
try { try {
const payload = await verifySecureToken(token); const payload = await verifySecureToken(token);
res.status(200).json(payload); return res.status(200).json(payload);
} catch { } catch {
res.status(400).end(); return res.status(400).end();
} }
}; };

View File

@ -8,5 +8,5 @@ export default async (req, res) => {
const websites = await getWebsites(user_id); const websites = await getWebsites(user_id);
res.status(200).json({ websites }); return res.status(200).json({ websites });
}; };

View File

@ -1,3 +1,4 @@
import moment from 'moment-timezone';
import { getPageviewData } from 'lib/db'; import { getPageviewData } from 'lib/db';
import { useAuth } from 'lib/middleware'; import { useAuth } from 'lib/middleware';
@ -6,10 +7,14 @@ export default async (req, res) => {
const { id, start_at, end_at, unit, tz } = req.query; const { id, start_at, end_at, unit, tz } = req.query;
if (!moment.tz.zone(tz) || !['hour', 'day'].includes(unit)) {
return res.status(400).end();
}
const [pageviews, uniques] = await Promise.all([ const [pageviews, uniques] = await Promise.all([
getPageviewData(+id, new Date(+start_at), new Date(+end_at), tz, unit, '*'), getPageviewData(+id, new Date(+start_at), new Date(+end_at), tz, unit, '*'),
getPageviewData(+id, new Date(+start_at), new Date(+end_at), tz, unit, 'distinct session_id'), getPageviewData(+id, new Date(+start_at), new Date(+end_at), tz, unit, 'distinct session_id'),
]); ]);
res.status(200).json({ pageviews, uniques }); return res.status(200).json({ pageviews, uniques });
}; };

View File

@ -9,5 +9,5 @@ export default async (req, res) => {
const pageviews = await getPageviews(+id, new Date(+start_at), new Date(+end_at)); const pageviews = await getPageviews(+id, new Date(+start_at), new Date(+end_at));
res.status(200).json({ pageviews }); return res.status(200).json({ pageviews });
}; };