Toggle formatting on click. Renamed charts folder to metrics.

This commit is contained in:
Mike Cao 2020-08-21 13:43:42 -07:00
parent d9f3c385fb
commit e75593443a
19 changed files with 33 additions and 28 deletions

View file

@ -0,0 +1,45 @@
import React, { useState, useEffect } from 'react';
import { useSpring, animated } from 'react-spring';
import classNames from 'classnames';
import { get } from 'lib/web';
import styles from './ActiveUsers.module.css';
export default function ActiveUsers({ websiteId, className }) {
const [count, setCount] = useState(0);
async function loadData() {
const result = await get(`/api/website/${websiteId}/active`);
setCount(result?.[0]?.x);
}
const props = useSpring({
x: count,
from: { x: 0 },
});
useEffect(() => {
loadData();
const id = setInterval(() => loadData(), 60000);
return () => {
clearInterval(id);
};
}, []);
if (count === 0) {
return null;
}
return (
<div className={classNames(styles.container, className)}>
<div className={styles.dot} />
<div className={styles.text}>
<animated.div className={styles.value}>
{props.x.interpolate(x => x.toFixed(0))}
</animated.div>
<div>{`current vistor${count !== 1 ? 's' : ''}`}</div>
</div>
</div>
);
}

View file

@ -0,0 +1,22 @@
.container {
display: flex;
align-items: center;
}
.text {
display: flex;
font-size: var(--font-size-normal);
}
.value {
font-weight: 600;
margin-right: 4px;
}
.dot {
background: var(--green400);
width: 10px;
height: 10px;
border-radius: 100%;
margin-right: 10px;
}

View file

@ -0,0 +1,17 @@
import React from 'react';
import { useSpring, animated } from 'react-spring';
import { formatNumber } from '../../lib/format';
import styles from './MetricCard.module.css';
const MetricCard = ({ value = 0, label, format = formatNumber }) => {
const props = useSpring({ x: value, from: { x: 0 } });
return (
<div className={styles.card}>
<animated.div className={styles.value}>{props.x.interpolate(x => format(x))}</animated.div>
<div className={styles.label}>{label}</div>
</div>
);
};
export default MetricCard;

View file

@ -0,0 +1,18 @@
.card {
display: flex;
flex-direction: column;
justify-content: center;
min-width: 140px;
}
.value {
font-size: var(--font-size-xlarge);
line-height: 40px;
min-height: 40px;
font-weight: 600;
white-space: nowrap;
}
.label {
font-size: var(--font-size-normal);
}

View file

@ -0,0 +1,48 @@
import React, { useState, useEffect } from 'react';
import classNames from 'classnames';
import MetricCard from './MetricCard';
import { get } from 'lib/web';
import { formatShortTime, formatNumber, formatLongNumber } from 'lib/format';
import styles from './MetricsBar.module.css';
export default function MetricsBar({ websiteId, startDate, endDate, className }) {
const [data, setData] = useState({});
const [format, setFormat] = useState(true);
const { pageviews, uniques, bounces, totaltime } = data;
const formatFunc = format ? formatLongNumber : formatNumber;
async function loadData() {
setData(
await get(`/api/website/${websiteId}/metrics`, {
start_at: +startDate,
end_at: +endDate,
}),
);
}
function handleSetFormat() {
setFormat(state => !state);
}
useEffect(() => {
loadData();
}, [websiteId, startDate, endDate]);
return (
<div className={classNames(styles.bar, className)} onClick={handleSetFormat}>
<MetricCard label="Views" value={pageviews} format={formatFunc} />
<MetricCard label="Visitors" value={uniques} format={formatFunc} />
<MetricCard
label="Bounce rate"
value={uniques ? (bounces / uniques) * 100 : 0}
format={n => Number(n).toFixed(0) + '%'}
/>
<MetricCard
label="Average visit time"
value={totaltime && pageviews ? totaltime / (pageviews - bounces) : 0}
format={n => formatShortTime(n, ['m', 's'], ' ')}
/>
</div>
);
}

View file

@ -0,0 +1,10 @@
.bar {
display: flex;
cursor: pointer;
}
@media only screen and (max-width: 992px) {
.container > div:last-child {
display: none;
}
}

View file

@ -0,0 +1,175 @@
import React, { useState, useRef, useEffect, useCallback } from 'react';
import ReactTooltip from 'react-tooltip';
import classNames from 'classnames';
import ChartJS from 'chart.js';
import { format } from 'date-fns';
import styles from './PageviewsChart.module.css';
export default function PageviewsChart({
websiteId,
data,
unit,
animationDuration = 300,
className,
children,
}) {
const canvas = useRef();
const chart = useRef();
const [tooltip, setTooltip] = useState({});
const renderLabel = useCallback(
(label, index, values) => {
const d = new Date(values[index].value);
const n = data.pageviews.length;
switch (unit) {
case 'day':
if (n >= 15) {
return index % ~~(n / 15) === 0 ? format(d, 'MMM d') : '';
}
return format(d, 'EEE M/d');
case 'month':
return format(d, 'MMMM');
default:
return label;
}
},
[unit, data],
);
const renderTooltip = model => {
const { opacity, title, body, labelColors } = model;
if (!opacity) {
setTooltip(null);
} else {
const [label, value] = body[0].lines[0].split(':');
setTooltip({
title: title[0],
value,
label,
labelColor: labelColors[0].backgroundColor,
});
}
};
function draw() {
if (!canvas.current) return;
if (!chart.current) {
chart.current = new ChartJS(canvas.current, {
type: 'bar',
data: {
datasets: [
{
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,
},
stacked: true,
},
],
},
},
});
} else {
const {
data: { datasets },
options,
} = chart.current;
datasets[0].data = data.uniques;
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();
}
}
useEffect(() => {
if (data) {
draw();
setTooltip(null);
}
}, [data]);
return (
<div
data-tip=""
data-for={`${websiteId}-tooltip`}
className={classNames(styles.chart, className)}
>
<canvas ref={canvas} width={960} height={400} />
<ReactTooltip id={`${websiteId}-tooltip`}>
{tooltip ? <Tooltip {...tooltip} /> : null}
</ReactTooltip>
{children}
</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

@ -0,0 +1,43 @@
.chart {
position: relative;
}
.tooltip {
pointer-events: none;
z-index: 1;
}
.content {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
color: var(--gray50);
text-align: center;
}
.title {
font-size: var(--font-size-xsmall);
font-weight: 600;
}
.metric {
display: flex;
justify-content: center;
align-items: center;
font-size: var(--font-size-small);
font-weight: 600;
}
.dot {
position: relative;
overflow: hidden;
border-radius: 100%;
margin-right: 8px;
background: var(--gray50);
}
.color {
width: 10px;
height: 10px;
}

View file

@ -0,0 +1,31 @@
import React from 'react';
import classNames from 'classnames';
import Button from '../common/Button';
import { getDateRange } from 'lib/date';
import styles from './QuickButtons.module.css';
const options = {
'24hour': '24h',
'7day': '7d',
'30day': '30d',
};
export default function QuickButtons({ value, onChange }) {
function handleClick(value) {
onChange(getDateRange(value));
}
return (
<div className={styles.buttons}>
{Object.keys(options).map(key => (
<Button
key={key}
className={classNames(styles.button, { [styles.active]: value === key })}
onClick={() => handleClick(key)}
>
{options[key]}
</Button>
))}
</div>
);
}

View file

@ -0,0 +1,33 @@
.buttons {
display: flex;
align-content: center;
position: absolute;
top: 0;
right: 0;
margin: auto;
}
.buttons button + button {
margin-left: 10px;
}
.buttons .button {
font-size: var(--font-size-xsmall);
padding: 4px 8px;
}
.active {
font-weight: 600;
}
@media only screen and (max-width: 768px) {
.buttons button:last-child {
display: none;
}
}
@media only screen and (max-width: 576px) {
.buttons {
display: none;
}
}

View file

@ -0,0 +1,138 @@
import React, { useState, useEffect, useMemo } from 'react';
import { FixedSizeList } from 'react-window';
import { useSpring, animated, config } from 'react-spring';
import classNames from 'classnames';
import Button from 'components/common/Button';
import Arrow from 'assets/arrow-right.svg';
import { get } from 'lib/web';
import { percentFilter } from 'lib/filters';
import { formatNumber, formatLongNumber } from 'lib/format';
import styles from './RankingsChart.module.css';
export default function RankingsChart({
title,
websiteId,
startDate,
endDate,
type,
heading,
className,
dataFilter,
limit,
onDataLoad = () => {},
onExpand = () => {},
}) {
const [data, setData] = useState();
const [format, setFormat] = useState(true);
const formatFunc = format ? formatLongNumber : formatNumber;
const shouldAnimate = limit > 0;
const rankings = useMemo(() => {
if (data) {
const items = dataFilter ? dataFilter(data) : data;
if (limit) {
return items.filter((e, i) => i < limit);
}
return items;
}
return [];
}, [data]);
async function loadData() {
const data = await get(`/api/website/${websiteId}/rankings`, {
start_at: +startDate,
end_at: +endDate,
type,
});
const updated = percentFilter(data);
setData(updated);
onDataLoad(updated);
}
function handleSetFormat() {
setFormat(state => !state);
}
function getRow(x, y, z) {
return (
<AnimatedRow
key={x}
label={x}
value={y}
percent={z}
animate={shouldAnimate}
format={formatFunc}
onClick={handleSetFormat}
/>
);
}
const Row = ({ index, style }) => {
const { x, y, z } = rankings[index];
return <div style={style}>{getRow(x, y, z)}</div>;
};
useEffect(() => {
if (websiteId) {
loadData();
}
}, [websiteId, startDate, endDate, type]);
if (!data) {
return null;
}
return (
<div className={classNames(styles.container, className)}>
<div className={styles.header}>
<div className={styles.title}>{title}</div>
<div className={styles.heading} onClick={handleSetFormat}>
{heading}
</div>
</div>
<div className={styles.body}>
{limit ? (
rankings.map(({ x, y, z }) => getRow(x, y, z))
) : (
<FixedSizeList height={600} itemCount={rankings.length} itemSize={30}>
{Row}
</FixedSizeList>
)}
</div>
<div className={styles.footer}>
{limit && data.length > limit && (
<Button icon={<Arrow />} size="xsmall" onClick={() => onExpand(type)}>
<div>More</div>
</Button>
)}
</div>
</div>
);
}
const AnimatedRow = ({ label, value = 0, percent, animate, format, onClick }) => {
const props = useSpring({
width: percent,
y: value,
from: { width: 0, y: 0 },
config: animate ? config.default : { duration: 0 },
});
return (
<div className={styles.row} onClick={onClick}>
<div className={styles.label}>{label}</div>
<animated.div className={styles.value}>{props.y?.interpolate(format)}</animated.div>
<div className={styles.percent}>
<animated.div
className={styles.bar}
style={{ width: props.width.interpolate(n => `${n}%`) }}
/>
<animated.span className={styles.percentValue}>
{props.width.interpolate(n => `${n.toFixed(0)}%`)}
</animated.span>
</div>
</div>
);
};

View file

@ -0,0 +1,105 @@
.container {
position: relative;
min-height: 460px;
font-size: var(--font-size-small);
padding: 20px 0;
display: flex;
flex-direction: column;
}
.header {
display: flex;
line-height: 40px;
}
.title {
flex: 1;
font-weight: 600;
font-size: var(--font-size-normal);
}
.heading {
font-size: var(--font-size-small);
text-align: center;
width: 100px;
cursor: pointer;
}
.row {
position: relative;
height: 30px;
line-height: 30px;
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 5px;
overflow: hidden;
}
.label {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
flex: 2;
}
.label:empty {
color: #b3b3b3;
}
.label:empty:before {
content: 'Unknown';
}
.value {
width: 50px;
text-align: right;
margin-right: 10px;
font-weight: 600;
cursor: pointer;
}
.percent {
position: relative;
width: 50px;
color: #6e6e6e;
border-left: 1px solid var(--gray600);
padding-left: 10px;
z-index: 1;
}
.bar {
position: absolute;
top: 0;
left: 0;
height: 30px;
opacity: 0.1;
background: var(--primary400);
z-index: -1;
}
.body {
position: relative;
flex: 1;
overflow: hidden;
}
.body:empty:before {
content: 'No data available';
color: var(--gray500);
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.footer {
display: flex;
justify-content: center;
}
@media only screen and (max-width: 992px) {
.container {
min-height: auto;
}
}

View file

@ -0,0 +1,92 @@
import React, { useState, useEffect, useMemo, useRef } from 'react';
import classNames from 'classnames';
import PageviewsChart from './PageviewsChart';
import CheckVisible from '../helpers/CheckVisible';
import MetricsBar from './MetricsBar';
import QuickButtons from './QuickButtons';
import DateFilter from '../common/DateFilter';
import StickyHeader from '../helpers/StickyHeader';
import { get } from 'lib/web';
import { getDateArray, getDateRange, getTimezone } from 'lib/date';
import styles from './WebsiteChart.module.css';
export default function WebsiteChart({
websiteId,
defaultDateRange = '7day',
stickyHeader = false,
onDataLoad = () => {},
onDateChange = () => {},
}) {
const [data, setData] = useState();
const [dateRange, setDateRange] = useState(getDateRange(defaultDateRange));
const { startDate, endDate, unit, value } = dateRange;
const [pageviews, uniques] = useMemo(() => {
if (data) {
return [
getDateArray(data.pageviews, startDate, endDate, unit),
getDateArray(data.uniques, startDate, endDate, unit),
];
}
return [[], []];
}, [data]);
function handleDateChange(values) {
setDateRange(values);
onDateChange(values);
}
async function loadData() {
const data = await get(`/api/website/${websiteId}/pageviews`, {
start_at: +startDate,
end_at: +endDate,
unit,
tz: getTimezone(),
});
setData(data);
onDataLoad(data);
}
useEffect(() => {
loadData();
}, [websiteId, startDate, endDate, unit]);
return (
<>
<div className={classNames(styles.header, 'row')}>
<StickyHeader
className={classNames(styles.metrics, 'col row')}
stickyClassName={styles.sticky}
enabled={stickyHeader}
>
<MetricsBar
className="col-12 col-md-9 col-lg-10"
websiteId={websiteId}
startDate={startDate}
endDate={endDate}
/>
<DateFilter
className="col-12 col-md-3 col-lg-2"
value={value}
onChange={handleDateChange}
/>
</StickyHeader>
</div>
<div className="row">
<CheckVisible className="col">
{visible => (
<PageviewsChart
websiteId={websiteId}
data={{ pageviews, uniques }}
unit={unit}
animationDuration={visible ? 300 : 0}
>
<QuickButtons value={value} onChange={handleDateChange} />
</PageviewsChart>
)}
</CheckVisible>
</div>
</>
);
}

View file

@ -0,0 +1,31 @@
.container {
display: flex;
flex-direction: column;
}
.title {
font-size: var(--font-size-large);
line-height: 60px;
font-weight: 600;
}
.header {
min-height: 90px;
}
.metrics {
position: relative;
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 0;
}
.sticky {
position: fixed;
top: 0;
margin: auto;
background: var(--gray50);
border-bottom: 1px solid var(--gray300);
z-index: 3;
}

View file

@ -0,0 +1,38 @@
import React from 'react';
import { useRouter } from 'next/router';
import PageHeader from 'components/layout/PageHeader';
import Link from 'components/common/Link';
import Button from 'components/common/Button';
import ActiveUsers from './ActiveUsers';
import Arrow from 'assets/arrow-right.svg';
import styles from './WebsiteHeader.module.css';
export default function WebsiteHeader({ websiteId, name, showLink = false }) {
const router = useRouter();
return (
<PageHeader>
{showLink ? (
<Link href="/website/[...id]" as={`/website/${websiteId}/${name}`} className={styles.title}>
{name}
</Link>
) : (
<div className={styles.title}>{name}</div>
)}
<ActiveUsers className={styles.active} websiteId={websiteId} />
{showLink && (
<Button
icon={<Arrow />}
onClick={() =>
router.push('/website/[...id]', `/website/${websiteId}/${name}`, {
shallow: true,
})
}
size="small"
>
<div>View details</div>
</Button>
)}
</PageHeader>
);
}

View file

@ -0,0 +1,15 @@
.title {
color: var(--gray-900);
font-size: var(--font-size-large);
line-height: var(--font-size-large);
}
.button {
font-size: var(--font-size-small);
}
@media only screen and (max-width: 576px) {
.active {
display: none;
}
}