Details page.

This commit is contained in:
Mike Cao 2020-07-31 19:05:14 -07:00
parent 5b45301178
commit ac2612924e
12 changed files with 312 additions and 10 deletions

View File

@ -0,0 +1,63 @@
import React, { useState, useEffect, useMemo } from 'react';
import { useSpring, animated } from 'react-spring';
import classNames from 'classnames';
import { get } from 'lib/web';
import styles from './RankingsChart.module.css';
export default function RankingsChart({
title,
websiteId,
startDate,
endDate,
type,
className,
filterData,
}) {
const [data, setData] = useState();
const rankings = useMemo(() => (data && filterData ? filterData(data) : data), [data]);
const total = useMemo(() => rankings?.reduce((n, { y }) => n + y, 0) || 0, [rankings]);
async function loadData() {
setData(
await get(`/api/website/${websiteId}/rankings`, {
start_at: +startDate,
end_at: +endDate,
type,
}),
);
}
useEffect(() => {
if (websiteId) {
loadData();
}
}, [websiteId, startDate, endDate, type]);
if (!data) {
return <h1>loading...</h1>;
}
return (
<div className={classNames(styles.container, className)}>
<div className={styles.title}>{title}</div>
{rankings.map(({ x, y }, i) => (i <= 10 ? <Row label={x} value={y} total={total} /> : null))}
</div>
);
}
const Row = ({ label, value, total }) => {
const props = useSpring({ width: `${(value / total) * 100}%`, from: { width: '0%' } });
const valueProps = useSpring({ y: value, from: { y: 0 } });
return (
<div className={styles.row}>
<div className={styles.label}>{label}</div>
<animated.div className={styles.value}>
{valueProps.y.interpolate(y => y.toFixed(0))}
</animated.div>
<animated.div className={styles.bar} style={{ ...props }} />
</div>
);
};

View File

@ -0,0 +1,38 @@
.container {
position: relative;
min-height: 390px;
}
.row {
position: relative;
height: 30px;
line-height: 30px;
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 5px;
}
.title {
font-weight: 600;
line-height: 40px;
}
.label {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
flex: 1;
z-index: 1;
}
.value {
text-align: right;
}
.bar {
position: absolute;
height: 30px;
opacity: 0.1;
background: #2680eb;
}

View File

@ -7,9 +7,13 @@ import QuickButtons from './QuickButtons';
import styles from './WebsiteChart.module.css';
import DateFilter from './DateFilter';
export default function WebsiteChart({ title, websiteId }) {
export default function WebsiteChart({
websiteId,
defaultDateRange = '7day',
onDateChange = () => {},
}) {
const [data, setData] = useState();
const [dateRange, setDateRange] = useState(getDateRange('7day'));
const [dateRange, setDateRange] = useState(getDateRange(defaultDateRange));
const { startDate, endDate, unit, value } = dateRange;
const [pageviews, uniques] = useMemo(() => {
@ -24,6 +28,7 @@ export default function WebsiteChart({ title, websiteId }) {
function handleDateChange(values) {
setDateRange(values);
onDateChange(values);
}
async function loadData() {
@ -43,7 +48,6 @@ export default function WebsiteChart({ title, websiteId }) {
return (
<div className={styles.container}>
<div className={styles.title}>{title}</div>
<div className={styles.header}>
<MetricsBar websiteId={websiteId} startDate={startDate} endDate={endDate} />
<DateFilter value={value} onChange={handleDateChange} />

View File

@ -0,0 +1,118 @@
import React, { useEffect, useState } from 'react';
import classNames from 'classnames';
import { get } from 'lib/web';
import WebsiteChart from './WebsiteChart';
import RankingsChart from './RankingsChart';
import { getDateRange } from '../lib/date';
import styles from './WebsiteDetails.module.css';
const osFilter = data => data.map(({ x, y }) => ({ x: !x ? 'Unknown' : x, y }));
const urlFilter = data => data.filter(({ x }) => x !== '' && !x.startsWith('#'));
const refFilter = data =>
data.filter(({ x }) => x !== '' && !x.startsWith('/') && !x.startsWith('#'));
const deviceFilter = data => {
const devices = data.reduce(
(obj, { x, y }) => {
const [width] = x.split('x');
if (width >= 1920) {
obj.desktop += +y;
} else if (width >= 1024) {
obj.laptop += +y;
} else if (width >= 767) {
obj.tablet += +y;
} else {
obj.mobile += +y;
}
return obj;
},
{ desktop: 0, laptop: 0, tablet: 0, mobile: 0 },
);
return Object.keys(devices).map(key => ({ x: key, y: devices[key] }));
};
export default function WebsiteDetails({ websiteId, defaultDateRange = '7day' }) {
const [data, setData] = useState();
const [dateRange, setDateRange] = useState(getDateRange(defaultDateRange));
const { startDate, endDate } = dateRange;
async function loadData() {
setData(await get(`/api/website/${websiteId}`));
}
function handleDateChange(values) {
setDateRange(values);
}
useEffect(() => {
if (websiteId) {
loadData();
}
}, [websiteId]);
if (!data) {
return <h1>loading...</h1>;
}
return (
<>
<div className="row">
<div className="col">
<h1>{data.label}</h1>
<WebsiteChart websiteId={data.website_id} onDateChange={handleDateChange} />
</div>
</div>
<div className={classNames(styles.row, 'row justify-content-between')}>
<RankingsChart
title="Top URLs"
type="url"
className="col-12 col-md-8 col-lg-6"
websiteId={data.website_id}
startDate={startDate}
endDate={endDate}
filterData={urlFilter}
/>
<RankingsChart
title="Top referrers"
type="referrer"
className="col-12 col-md-8 col-lg-6"
websiteId={data.website_id}
startDate={startDate}
endDate={endDate}
filterData={refFilter}
/>
</div>
<div className={classNames(styles.row, 'row justify-content-between')}>
<RankingsChart
title="Browsers"
type="browser"
className="col-12 col-md-8 col-lg-4"
websiteId={data.website_id}
startDate={startDate}
endDate={endDate}
/>
<RankingsChart
title="Operating system"
type="os"
className="col-12 col-md-8 col-lg-4"
websiteId={data.website_id}
startDate={startDate}
endDate={endDate}
filterData={osFilter}
/>
<RankingsChart
title="Devices"
type="screen"
className="col-12 col-md-8 col-lg-4"
websiteId={data.website_id}
startDate={startDate}
endDate={endDate}
filterData={deviceFilter}
/>
</div>
</>
);
}

View File

@ -0,0 +1,3 @@
.row {
margin: 20px 0;
}

View File

@ -1,4 +1,5 @@
import React, { useState, useEffect } from 'react';
import Link from 'next/link';
import { get } from 'lib/web';
import WebsiteChart from './WebsiteChart';
import styles from './WebsiteList.module.css';
@ -17,8 +18,15 @@ export default function WebsiteList() {
return (
<div className={styles.container}>
{data &&
data.websites.map(({ website_id, label }) => (
<WebsiteChart key={website_id} title={label} websiteId={website_id} />
data.websites.map(({ website_id, website_uuid, label }) => (
<>
<h2>
<Link href={`/${website_uuid}`}>
<a>{label}</a>
</Link>
</h2>
<WebsiteChart key={website_id} title={label} websiteId={website_id} />
</>
))}
</div>
);

View File

@ -154,6 +154,24 @@ export async function getPageviews(website_id, start_at, end_at) {
);
}
export async function getRankings(website_id, start_at, end_at, type, table) {
return runQuery(
prisma.queryRaw(
`
select distinct "${type}" x, count(*) y
from "${table}"
where website_id=$1
and created_at between $2 and $3
group by 1
order by 2 desc
`,
website_id,
start_at,
end_at,
),
);
}
export async function getPageviewData(
website_id,
start_at,

View File

@ -24,10 +24,6 @@ export function formatTime(val) {
}
export function formatShortTime(val, formats = ['m', 's'], space = '') {
if (val <= 0) {
return `0${formats[formats.length - 1]}`;
}
const { days, hours, minutes, seconds, ms } = parseTime(val);
let t = '';
@ -37,5 +33,9 @@ export function formatShortTime(val, formats = ['m', 's'], 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;
}

15
pages/[id].js Normal file
View File

@ -0,0 +1,15 @@
import React from 'react';
import { useRouter } from 'next/router';
import Layout from 'components/Layout';
import WebsiteDetails from '../components/WebsiteDetails';
export default function DetailsPage() {
const router = useRouter();
const { id } = router.query;
return (
<Layout>
<WebsiteDetails websiteId={id} />
</Layout>
);
}

View File

@ -0,0 +1,12 @@
import { getWebsite } from 'lib/db';
import { useAuth } from 'lib/middleware';
export default async (req, res) => {
await useAuth(req, res);
const { id } = req.query;
const website = await getWebsite(id);
return res.status(200).json(website);
};

View File

@ -2,12 +2,14 @@ import moment from 'moment-timezone';
import { getPageviewData } from 'lib/db';
import { useAuth } from 'lib/middleware';
const unitTypes = ['month', 'hour', 'day'];
export default async (req, res) => {
await useAuth(req, res);
const { id, start_at, end_at, unit, tz } = req.query;
if (!moment.tz.zone(tz) || !['month', 'hour', 'day'].includes(unit)) {
if (!moment.tz.zone(tz) || !unitTypes.includes(unit)) {
return res.status(400).end();
}

View File

@ -0,0 +1,21 @@
import { getRankings } from 'lib/db';
import { useAuth } from 'lib/middleware';
const sessionColumns = ['browser', 'os', 'screen'];
const pageviewColumns = ['url', 'referrer'];
export default async (req, res) => {
await useAuth(req, res);
const { id, type, start_at, end_at } = req.query;
if (!sessionColumns.includes(type) && !pageviewColumns.includes(type)) {
return res.status(400).end();
}
const table = sessionColumns.includes(type) ? 'session' : 'pageview';
const rankings = await getRankings(+id, new Date(+start_at), new Date(+end_at), type, table);
return res.status(200).json(rankings);
};