diff --git a/components/RankingsChart.js b/components/RankingsChart.js new file mode 100644 index 00000000..920cf8a5 --- /dev/null +++ b/components/RankingsChart.js @@ -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

loading...

; + } + + return ( +
+
{title}
+ {rankings.map(({ x, y }, i) => (i <= 10 ? : null))} +
+ ); +} + +const Row = ({ label, value, total }) => { + const props = useSpring({ width: `${(value / total) * 100}%`, from: { width: '0%' } }); + const valueProps = useSpring({ y: value, from: { y: 0 } }); + + return ( +
+
{label}
+ + {valueProps.y.interpolate(y => y.toFixed(0))} + + +
+ ); +}; diff --git a/components/RankingsChart.module.css b/components/RankingsChart.module.css new file mode 100644 index 00000000..7d0f3437 --- /dev/null +++ b/components/RankingsChart.module.css @@ -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; +} diff --git a/components/WebsiteChart.js b/components/WebsiteChart.js index 53430c69..6b883316 100644 --- a/components/WebsiteChart.js +++ b/components/WebsiteChart.js @@ -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 (
-
{title}
diff --git a/components/WebsiteDetails.js b/components/WebsiteDetails.js new file mode 100644 index 00000000..ba6d56c0 --- /dev/null +++ b/components/WebsiteDetails.js @@ -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

loading...

; + } + + return ( + <> +
+
+

{data.label}

+ +
+
+
+ + +
+
+ + + +
+ + ); +} diff --git a/components/WebsiteDetails.module.css b/components/WebsiteDetails.module.css new file mode 100644 index 00000000..8497bc84 --- /dev/null +++ b/components/WebsiteDetails.module.css @@ -0,0 +1,3 @@ +.row { + margin: 20px 0; +} diff --git a/components/WebsiteList.js b/components/WebsiteList.js index 225d462b..e0667d1b 100644 --- a/components/WebsiteList.js +++ b/components/WebsiteList.js @@ -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 (
{data && - data.websites.map(({ website_id, label }) => ( - + data.websites.map(({ website_id, website_uuid, label }) => ( + <> +

+ + {label} + +

+ + ))}
); diff --git a/lib/db.js b/lib/db.js index 8b3ae6d7..244a4d2b 100644 --- a/lib/db.js +++ b/lib/db.js @@ -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, diff --git a/lib/format.js b/lib/format.js index c00f84d0..35d173a2 100644 --- a/lib/format.js +++ b/lib/format.js @@ -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; } diff --git a/pages/[id].js b/pages/[id].js new file mode 100644 index 00000000..e9321edd --- /dev/null +++ b/pages/[id].js @@ -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 ( + + + + ); +} diff --git a/pages/api/website/[id]/index.js b/pages/api/website/[id]/index.js new file mode 100644 index 00000000..0a215219 --- /dev/null +++ b/pages/api/website/[id]/index.js @@ -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); +}; diff --git a/pages/api/website/[id]/pageviews.js b/pages/api/website/[id]/pageviews.js index 1987f72f..a2a2fbab 100644 --- a/pages/api/website/[id]/pageviews.js +++ b/pages/api/website/[id]/pageviews.js @@ -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(); } diff --git a/pages/api/website/[id]/rankings.js b/pages/api/website/[id]/rankings.js new file mode 100644 index 00000000..8d76e5fb --- /dev/null +++ b/pages/api/website/[id]/rankings.js @@ -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); +};