Events chart.

This commit is contained in:
Mike Cao 2020-08-27 03:42:24 -07:00
parent 5f47f328be
commit 4618dc7f15
9 changed files with 220 additions and 226 deletions

View File

@ -17,6 +17,7 @@ import OSTable from './metrics/OSTable';
import DevicesTable from './metrics/DevicesTable'; import DevicesTable from './metrics/DevicesTable';
import CountriesTable from './metrics/CountriesTable'; import CountriesTable from './metrics/CountriesTable';
import EventsTable from './metrics/EventsTable'; import EventsTable from './metrics/EventsTable';
import EventsChart from './metrics/EventsChart';
export default function WebsiteDetails({ websiteId, defaultDateRange = '7day' }) { export default function WebsiteDetails({ websiteId, defaultDateRange = '7day' }) {
const [data, setData] = useState(); const [data, setData] = useState();
@ -25,7 +26,7 @@ export default function WebsiteDetails({ websiteId, defaultDateRange = '7day' })
const [dateRange, setDateRange] = useState(getDateRange(defaultDateRange)); const [dateRange, setDateRange] = useState(getDateRange(defaultDateRange));
const [expand, setExpand] = useState(); const [expand, setExpand] = useState();
const [showEvents, setShowEvents] = useState(false); const [showEvents, setShowEvents] = useState(false);
const { startDate, endDate } = dateRange; const { startDate, endDate, unit } = dateRange;
const BackButton = () => ( const BackButton = () => (
<Button <Button
@ -55,10 +56,15 @@ export default function WebsiteDetails({ websiteId, defaultDateRange = '7day' })
{ label: 'Events', value: 'event', component: EventsTable }, { label: 'Events', value: 'event', component: EventsTable },
]; ];
const tableProps = { const dataProps = {
websiteId, websiteId,
startDate, startDate,
endDate, endDate,
unit,
};
const tableProps = {
...dataProps,
limit: 10, limit: 10,
onExpand: handleExpand, onExpand: handleExpand,
websiteDomain: data?.domain, websiteDomain: data?.domain,
@ -66,6 +72,10 @@ export default function WebsiteDetails({ websiteId, defaultDateRange = '7day' })
const DetailsComponent = expand?.component; const DetailsComponent = expand?.component;
function getSelectedMenuOption(value) {
return menuOptions.find(e => e.value === value);
}
async function loadData() { async function loadData() {
setData(await get(`/api/website/${websiteId}`)); setData(await get(`/api/website/${websiteId}`));
} }
@ -79,11 +89,11 @@ export default function WebsiteDetails({ websiteId, defaultDateRange = '7day' })
} }
function handleExpand(value) { function handleExpand(value) {
setExpand(menuOptions.find(e => e.value === value)); setExpand(getSelectedMenuOption(value));
} }
function handleMenuSelect(value) { function handleMenuSelect(value) {
setExpand(menuOptions.find(e => e.value === value)); setExpand(getSelectedMenuOption(value));
} }
useEffect(() => { useEffect(() => {
@ -142,7 +152,9 @@ export default function WebsiteDetails({ websiteId, defaultDateRange = '7day' })
<div className="col-12 col-md-12 col-lg-4"> <div className="col-12 col-md-12 col-lg-4">
<EventsTable {...tableProps} onDataLoad={data => setShowEvents(data.length > 0)} /> <EventsTable {...tableProps} onDataLoad={data => setShowEvents(data.length > 0)} />
</div> </div>
<div className="col-12 col-md-12 col-lg-8">events</div> <div className="col-12 col-md-12 col-lg-8">
<EventsChart {...dataProps} />
</div>
</div> </div>
</> </>
)} )}

View File

@ -12,6 +12,7 @@ export default function BarChart({
records, records,
animationDuration = 300, animationDuration = 300,
className, className,
stacked = false,
onUpdate = () => {}, onUpdate = () => {},
}) { }) {
const canvas = useRef(); const canvas = useRef();
@ -54,70 +55,75 @@ export default function BarChart({
} }
}; };
function draw() { const createChart = () => {
if (!chart.current) { chart.current = new ChartJS(canvas.current, {
chart.current = new ChartJS(canvas.current, { type: 'bar',
type: 'bar', data: {
data: { datasets,
datasets, },
options: {
animation: {
duration: animationDuration,
}, },
options: { tooltips: {
animation: { enabled: false,
duration: animationDuration, custom: renderTooltip,
},
tooltips: {
enabled: false,
custom: renderTooltip,
},
hover: {
animationDuration: 0,
},
responsiveAnimationDuration: 0,
scales: {
xAxes: [
{
type: 'time',
distribution: 'series',
time: {
unit,
tooltipFormat: 'ddd MMMM DD YYYY',
},
ticks: {
callback: renderLabel,
minRotation: 0,
maxRotation: 0,
},
gridLines: {
display: false,
},
offset: true,
stacked: true,
},
],
yAxes: [
{
ticks: {
beginAtZero: true,
},
},
],
},
}, },
}); hover: {
} else { animationDuration: 0,
const { options } = chart.current; },
responsiveAnimationDuration: 0,
scales: {
xAxes: [
{
type: 'time',
distribution: 'series',
time: {
unit,
tooltipFormat: 'ddd MMMM DD YYYY',
},
ticks: {
callback: renderLabel,
minRotation: 0,
maxRotation: 0,
},
gridLines: {
display: false,
},
offset: true,
stacked: true,
},
],
yAxes: [
{
ticks: {
beginAtZero: true,
},
stacked,
},
],
},
},
});
};
options.scales.xAxes[0].time.unit = unit; const updateChart = () => {
options.scales.xAxes[0].ticks.callback = renderLabel; const { options } = chart.current;
onUpdate(chart.current); options.scales.xAxes[0].time.unit = unit;
} options.scales.xAxes[0].ticks.callback = renderLabel;
}
onUpdate(chart.current);
};
useEffect(() => { useEffect(() => {
if (datasets) { if (datasets) {
draw(); if (!chart.current) {
setTooltip(null); createChart();
} else {
setTooltip(null);
updateChart();
}
} }
}, [datasets]); }, [datasets]);

View File

@ -1,174 +1,84 @@
import React, { useState, useRef, useEffect, useCallback } from 'react'; import React, { useState, useEffect } from 'react';
import ReactTooltip from 'react-tooltip';
import classNames from 'classnames'; import classNames from 'classnames';
import ChartJS from 'chart.js'; import BarChart from './BarChart';
import { format } from 'date-fns'; import { get } from 'lib/web';
import styles from './EventsChart.module.css'; import { getTimezone, getDateArray } from 'lib/date';
import styles from './PageviewsChart.module.css';
export default function EventsChart({ const COLORS = [
websiteId, 'rgba(38, 128, 235, 0.5)',
data, 'rgba(227, 72, 80, 0.5)',
unit, 'rgba(45, 157, 120, 0.5)',
animationDuration = 300, 'rgba(103, 103, 236, 0.5)',
className, 'rgba(68, 181, 86, 0.5)',
children, 'rgba(146, 86, 217, 0.5)',
}) { ];
const canvas = useRef();
const chart = useRef();
const [tooltip, setTooltip] = useState({});
const renderLabel = useCallback( export default function EventsChart({ websiteId, startDate, endDate, unit, className }) {
(label, index, values) => { const [data, setData] = useState();
const d = new Date(values[index].value);
const n = data.pageviews.length;
switch (unit) { async function loadData() {
case 'day': const data = await get(`/api/website/${websiteId}/events`, {
if (n >= 15) { start_at: +startDate,
return index % ~~(n / 15) === 0 ? format(d, 'MMM d') : ''; end_at: +endDate,
} unit,
return format(d, 'EEE M/d'); tz: getTimezone(),
case 'month': });
return format(d, 'MMMM'); console.log({ data });
default: const map = data.reduce((obj, { x, t, y }) => {
return label; if (!obj[x]) {
obj[x] = [];
} }
},
[unit, data],
);
const renderTooltip = model => { obj[x].push({ t, y });
const { opacity, title, body, labelColors } = model;
if (!opacity) { return obj;
setTooltip(null); }, {});
} else {
const [label, value] = body[0].lines[0].split(':');
setTooltip({ Object.keys(map).forEach(key => {
title: title[0], map[key] = getDateArray(map[key], startDate, endDate, unit);
value, });
label,
labelColor: labelColors[0].backgroundColor,
});
}
};
function draw() { setData(map);
if (!canvas.current) return; }
if (!chart.current) { function handleUpdate(chart) {
chart.current = new ChartJS(canvas.current, { const {
type: 'bar', data: { datasets },
data: { options,
datasets: [ } = chart;
{
label: 'unique visitors',
data: data.uniques,
lineTension: 0,
backgroundColor: 'rgb(38, 128, 235, 0.4)',
borderColor: 'rgb(13, 102, 208, 0.4)',
borderWidth: 1,
},
{
label: 'page views',
data: data.pageviews,
lineTension: 0,
backgroundColor: 'rgb(38, 128, 235, 0.2)',
borderColor: 'rgb(13, 102, 208, 0.2)',
borderWidth: 1,
},
],
},
options: {
animation: {
duration: animationDuration,
},
tooltips: {
enabled: false,
custom: renderTooltip,
},
hover: {
animationDuration: 0,
},
scales: {
xAxes: [
{
type: 'time',
distribution: 'series',
time: {
unit,
tooltipFormat: 'ddd MMMM DD YYYY',
},
ticks: {
callback: renderLabel,
maxRotation: 0,
},
gridLines: {
display: false,
},
offset: true,
stacked: true,
},
],
yAxes: [
{
ticks: {
beginAtZero: true,
},
},
],
},
},
});
} else {
const {
data: { datasets },
options,
} = chart.current;
datasets[0].data = data.uniques; datasets[0].data = data.uniques;
datasets[1].data = data.pageviews; datasets[1].data = data.pageviews;
options.scales.xAxes[0].time.unit = unit;
options.scales.xAxes[0].ticks.callback = renderLabel;
options.animation.duration = animationDuration;
chart.current.update(); chart.update();
}
} }
useEffect(() => { useEffect(() => {
if (data) { loadData();
draw(); }, [websiteId, startDate, endDate]);
setTooltip(null);
} if (!data) {
}, [data]); return null;
}
return ( return (
<div <div className={classNames(styles.chart, className)}>
data-tip="" <BarChart
data-for={`${websiteId}-tooltip`} chartId={websiteId}
className={classNames(styles.chart, className)} datasets={Object.keys(data).map((key, index) => ({
> label: key,
<canvas ref={canvas} width={960} height={400} /> data: data[key],
<ReactTooltip id={`${websiteId}-tooltip`}> lineTension: 0,
{tooltip ? <Tooltip {...tooltip} /> : null} backgroundColor: COLORS[index],
</ReactTooltip> borderColor: COLORS[index],
{children} borderWidth: 1,
}))}
unit={unit}
records={7}
onUpdate={handleUpdate}
stacked
/>
</div> </div>
); );
} }
const Tooltip = ({ title, value, label, labelColor }) => (
<div className={styles.tooltip}>
<div className={styles.content}>
<div className={styles.title}>{title}</div>
<div className={styles.metric}>
<div className={styles.dot}>
<div className={styles.color} style={{ backgroundColor: labelColor }} />
</div>
{value} {label}
</div>
</div>
</div>
);

View File

@ -1,7 +1,6 @@
import React, { useState, useRef, useEffect, useCallback } from 'react'; import React from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import BarChart from './BarChart'; import BarChart from './BarChart';
import { format } from 'date-fns';
import styles from './PageviewsChart.module.css'; import styles from './PageviewsChart.module.css';
export default function PageviewsChart({ websiteId, data, unit, className, animationDuration }) { export default function PageviewsChart({ websiteId, data, unit, className, animationDuration }) {

View File

@ -108,7 +108,7 @@ export function getDateArray(data, startDate, endDate, unit) {
const t = add(startDate, i); const t = add(startDate, i);
const y = findData(t); const y = findData(t);
arr.push({ t, y }); arr.push({ ...data[i], t, y });
} }
return arr; return arr;

View File

@ -401,7 +401,7 @@ export function getActiveVisitors(website_id) {
return Promise.resolve([]); return Promise.resolve([]);
} }
export function getEvents(website_id, start_at, end_at) { export function getEventRankings(website_id, start_at, end_at) {
const db = getDatabase(); const db = getDatabase();
if (db === POSTGRESQL) { if (db === POSTGRESQL) {
@ -438,3 +438,48 @@ export function getEvents(website_id, start_at, end_at) {
return Promise.resolve([]); return Promise.resolve([]);
} }
export function getEvents(website_id, start_at, end_at, timezone = 'utc', unit = 'day') {
const db = getDatabase();
if (db === POSTGRESQL) {
return prisma.$queryRaw(
`
select
event_value x,
date_trunc('${unit}', created_at at time zone '${timezone}') t,
count(*) y
from event
where website_id=$1
and created_at between $2 and $3
group by 1, 2
order by 2
`,
website_id,
start_at,
end_at,
);
}
if (db === MYSQL) {
const tz = moment.tz(timezone).format('Z');
return prisma.$queryRaw(
`
select
event_value x,
date_trunc('${unit}', convert_tz(created_at,'+00:00','${tz}')) t,
count(*) y
from event
where website_id=?
and created_at between ? and ?
group by 1, 2
order by 2
`,
website_id,
start_at,
end_at,
);
}
return Promise.resolve([]);
}

View File

@ -0,0 +1,21 @@
import moment from 'moment-timezone';
import { getEvents } from 'lib/queries';
import { ok, badRequest } from 'lib/response';
const unitTypes = ['month', 'hour', 'day'];
export default async (req, res) => {
const { id, start_at, end_at, unit, tz } = req.query;
if (!moment.tz.zone(tz) || !unitTypes.includes(unit)) {
return badRequest(res);
}
const websiteId = +id;
const startDate = new Date(+start_at);
const endDate = new Date(+end_at);
const events = await getEvents(websiteId, startDate, endDate, tz, unit);
return ok(res, events);
};

View File

@ -11,12 +11,13 @@ export default async (req, res) => {
return badRequest(res); return badRequest(res);
} }
const start = new Date(+start_at); const websiteId = +id;
const end = new Date(+end_at); const startDate = new Date(+start_at);
const endDate = new Date(+end_at);
const [pageviews, uniques] = await Promise.all([ const [pageviews, uniques] = await Promise.all([
getPageviews(+id, start, end, tz, unit, '*'), getPageviews(websiteId, startDate, endDate, tz, unit, '*'),
getPageviews(+id, start, end, tz, unit, 'distinct session_id'), getPageviews(websiteId, startDate, endDate, tz, unit, 'distinct session_id'),
]); ]);
return ok(res, { pageviews, uniques }); return ok(res, { pageviews, uniques });

View File

@ -1,4 +1,4 @@
import { getRankings, getEvents } from 'lib/queries'; import { getRankings, getEventRankings } from 'lib/queries';
import { ok, badRequest } from 'lib/response'; import { ok, badRequest } from 'lib/response';
const sessionColumns = ['browser', 'os', 'device', 'country']; const sessionColumns = ['browser', 'os', 'device', 'country'];
@ -15,7 +15,7 @@ export default async (req, res) => {
} }
if (type === 'event') { if (type === 'event') {
const events = await getEvents(websiteId, startDate, endDate); const events = await getEventRankings(websiteId, startDate, endDate);
return ok(res, events); return ok(res, events);
} }