mirror of
https://github.com/plausible/analytics.git
synced 2024-11-23 11:12:15 +03:00
Adds Main Graph Metric Selection (#1364)
* First pass bringing in previous graph improvements, and comparsion context * Swaps issue template to new issue form syntax * Indentation update * Indentation update? * More indentation * Intendation is hard * Finalized indentation? * Github indentation * Missing fields * Formatting changes * Checkbox changes * Uses new timeseries API, various UI improvements, descopes conversions, ToP from graphing * Fixes Mobile UI Issues * Improves point detection and display on hover * Fixes & adds tests for updated main-graph API route * Changelog * Changes to better metric option declaration & minor UI/default fixes * Fixes top stat tooltips showing unformatted numbers for special (non-rounded) top stats * Formatting * Fixes regression with dashed portion not stopping at present_index * Removes comparison + lint * Improves top stat active style * Removes comparison tests * Splits out tooltip and top stats Still needs: - Tests - Potentially more cleanup * Adds/moves tests for top stats * Formatting * Updates metric LS key, removes console log * Various fixes + cleanup * Makes tooltip position & style more consistent * Fixes test (returns import status on both main graph & top stats) * Fixes interaction with month dateFormatter * Fixes edge case tooltip behavior It was simpler than I thought :/ * Make the entire top stat clickable * Minor UI improvements * Fixes another tooltip visibility edge case + cleans up boolean algebra Co-authored-by: Uku Taht <Uku.taht@gmail.com>
This commit is contained in:
parent
8655973069
commit
3b97ecdc62
@ -55,8 +55,9 @@ All notable changes to this project will be documented in this file.
|
|||||||
- Ability to invite users to sites with different roles plausible/analytics#1122
|
- Ability to invite users to sites with different roles plausible/analytics#1122
|
||||||
- Option to configure a custom name for the script file
|
- Option to configure a custom name for the script file
|
||||||
- Add Conversion Rate to Top Sources, Top Pages Devices, Countries when filtered by a goal plausible/analytics#1299
|
- Add Conversion Rate to Top Sources, Top Pages Devices, Countries when filtered by a goal plausible/analytics#1299
|
||||||
|
- Choice of metric for main-graph both in UI and API (visitors, pageviews, bounce_rate, visit_duration) plausible/analytics#1364
|
||||||
- Add list view for countries report in dashboard plausible/analytics#1381
|
- Add list view for countries report in dashboard plausible/analytics#1381
|
||||||
- Add ability to view more than 100 custom goal properties plausible/analytics#1353
|
- Add ability to view more than 100 custom goal properties plausible/analytics#1382
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- Fix weekly report time range plausible/analytics#951
|
- Fix weekly report time range plausible/analytics#951
|
||||||
|
@ -32,6 +32,7 @@
|
|||||||
|
|
||||||
html {
|
html {
|
||||||
@apply text-gray-800;
|
@apply text-gray-800;
|
||||||
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@ -70,9 +71,8 @@ blockquote {
|
|||||||
.main-graph {
|
.main-graph {
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 0;
|
height: 0;
|
||||||
|
/* Formula is: (top row height + (graph height / graph width * 100%) - adjustment for stat selectors) */
|
||||||
/* Formula is: (top row height + (graph height / graph width * 100%)) */
|
padding-top: calc(168px + (451 / 1088 * 100%) - 20px);
|
||||||
padding-top: calc(168px + (451 / 1088 * 100%));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@screen sm {
|
@screen sm {
|
||||||
@ -89,7 +89,31 @@ blockquote {
|
|||||||
|
|
||||||
@screen lg {
|
@screen lg {
|
||||||
.main-graph {
|
.main-graph {
|
||||||
padding-top: calc(28px + (451 / 1088 * 100%));
|
padding-top: calc(28px + (451 / 1088 * 100%) - 10px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-stats-only {
|
||||||
|
position: relative;
|
||||||
|
height: 0;
|
||||||
|
padding-top: calc(158px + 15px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@screen sm {
|
||||||
|
.top-stats-only {
|
||||||
|
padding-top: calc(155px + 15px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@screen md {
|
||||||
|
.top-stats-only {
|
||||||
|
padding-top: calc(168px + 15px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@screen lg {
|
||||||
|
.top-stats-only {
|
||||||
|
padding-top: calc(75px + 15px);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -313,6 +337,17 @@ iframe[hidden] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#chartjs-tooltip {
|
||||||
|
background-color: rgba(25, 30, 56);
|
||||||
|
position: absolute;
|
||||||
|
font-size: 14px;
|
||||||
|
font-style: normal;
|
||||||
|
padding: 10px 12px;
|
||||||
|
pointer-events: none;
|
||||||
|
border-radius: 5px;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
.active-prop-heading {
|
.active-prop-heading {
|
||||||
/* Properties related to text-decoration are all here in one place. TailwindCSS does support underline but that's about it. */
|
/* Properties related to text-decoration are all here in one place. TailwindCSS does support underline but that's about it. */
|
||||||
text-decoration-line: underline;
|
text-decoration-line: underline;
|
||||||
|
16
assets/js/dashboard/comparison-consumer-hoc.js
Normal file
16
assets/js/dashboard/comparison-consumer-hoc.js
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { ComparisonContext } from './comparison-context'
|
||||||
|
|
||||||
|
export const withComparisonConsumer = (WrappedComponent) => {
|
||||||
|
return class extends React.Component {
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<ComparisonContext.Consumer>
|
||||||
|
{({data, modifyComparison}) => (
|
||||||
|
<WrappedComponent comparison={data} modifyComparison={modifyComparison} {...this.props} />
|
||||||
|
)}
|
||||||
|
</ComparisonContext.Consumer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
9
assets/js/dashboard/comparison-context.js
Normal file
9
assets/js/dashboard/comparison-context.js
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export const ComparisonContext = React.createContext({
|
||||||
|
data: {
|
||||||
|
enabled: false,
|
||||||
|
// timePeriod: '' // Saved for future update to allow for customizable compare period
|
||||||
|
},
|
||||||
|
modifyComparison: () => {}
|
||||||
|
});
|
36
assets/js/dashboard/comparison-provider-hoc.js
Normal file
36
assets/js/dashboard/comparison-provider-hoc.js
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { ComparisonContext } from './comparison-context'
|
||||||
|
import * as storage from './util/storage'
|
||||||
|
|
||||||
|
|
||||||
|
export const withComparisonProvider = (WrappedComponent) => {
|
||||||
|
return class extends React.Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props)
|
||||||
|
this.state = {
|
||||||
|
comparison: {
|
||||||
|
enabled: storage.getItem('comparison__enabled')=='true' || false,
|
||||||
|
// timePeriod: storage.getItem('comparison__period') || ''
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.updateComparisonData = this.updateComparisonData.bind(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateComparisonData(data) {
|
||||||
|
const {enabled} = data
|
||||||
|
|
||||||
|
storage.setItem('comparison__enabled', enabled || false)
|
||||||
|
|
||||||
|
this.setState({comparison: data})
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<ComparisonContext.Provider value={{data: this.state.comparison, modifyComparison: this.updateComparisonData}}>
|
||||||
|
<WrappedComponent {...this.props}/>
|
||||||
|
</ComparisonContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -315,6 +315,8 @@ class DatePicker extends React.Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
renderDropDownContent() {
|
renderDropDownContent() {
|
||||||
|
const { site } = this.props
|
||||||
|
|
||||||
if (this.state.mode === "menu") {
|
if (this.state.mode === "menu") {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -335,7 +337,7 @@ class DatePicker extends React.Component {
|
|||||||
</div>
|
</div>
|
||||||
<div className="py-1 border-b border-gray-200 dark:border-gray-500 date-option-group">
|
<div className="py-1 border-b border-gray-200 dark:border-gray-500 date-option-group">
|
||||||
{ this.renderLink('month', 'Month to Date') }
|
{ this.renderLink('month', 'Month to Date') }
|
||||||
{ this.renderLink('month', 'Last month', {date: lastMonth(this.props.site)}) }
|
{ this.renderLink('month', 'Last month', {date: lastMonth(site)}) }
|
||||||
</div>
|
</div>
|
||||||
<div className="py-1 border-b border-gray-200 dark:border-gray-500 date-option-group">
|
<div className="py-1 border-b border-gray-200 dark:border-gray-500 date-option-group">
|
||||||
{this.renderLink("year", "Year to Date")}
|
{this.renderLink("year", "Year to Date")}
|
||||||
|
@ -4,7 +4,7 @@ import Datepicker from './datepicker'
|
|||||||
import SiteSwitcher from './site-switcher'
|
import SiteSwitcher from './site-switcher'
|
||||||
import Filters from './filters'
|
import Filters from './filters'
|
||||||
import CurrentVisitors from './stats/current-visitors'
|
import CurrentVisitors from './stats/current-visitors'
|
||||||
import VisitorGraph from './stats/visitor-graph'
|
import VisitorGraph from './stats/graph/visitor-graph'
|
||||||
import Sources from './stats/sources'
|
import Sources from './stats/sources'
|
||||||
import Pages from './stats/pages'
|
import Pages from './stats/pages'
|
||||||
import Locations from './stats/locations';
|
import Locations from './stats/locations';
|
||||||
|
@ -5,6 +5,7 @@ import Historical from './historical'
|
|||||||
import Realtime from './realtime'
|
import Realtime from './realtime'
|
||||||
import {parseQuery} from './query'
|
import {parseQuery} from './query'
|
||||||
import * as api from './api'
|
import * as api from './api'
|
||||||
|
import { withComparisonProvider } from './comparison-provider-hoc';
|
||||||
|
|
||||||
const THIRTY_SECONDS = 30000
|
const THIRTY_SECONDS = 30000
|
||||||
|
|
||||||
@ -50,4 +51,4 @@ class Dashboard extends React.Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default withRouter(Dashboard)
|
export default withRouter(withComparisonProvider(Dashboard))
|
||||||
|
@ -3,7 +3,7 @@ import React from 'react';
|
|||||||
import Datepicker from './datepicker'
|
import Datepicker from './datepicker'
|
||||||
import SiteSwitcher from './site-switcher'
|
import SiteSwitcher from './site-switcher'
|
||||||
import Filters from './filters'
|
import Filters from './filters'
|
||||||
import VisitorGraph from './stats/visitor-graph'
|
import VisitorGraph from './stats/graph/visitor-graph'
|
||||||
import Sources from './stats/sources'
|
import Sources from './stats/sources'
|
||||||
import Pages from './stats/pages'
|
import Pages from './stats/pages'
|
||||||
import Locations from './stats/locations'
|
import Locations from './stats/locations'
|
||||||
|
209
assets/js/dashboard/stats/graph/graph-util.js
Normal file
209
assets/js/dashboard/stats/graph/graph-util.js
Normal file
@ -0,0 +1,209 @@
|
|||||||
|
import { METRIC_LABELS, METRIC_FORMATTER } from './visitor-graph'
|
||||||
|
import {parseUTCDate, formatMonthYYYY, formatDay} from '../../util/date'
|
||||||
|
|
||||||
|
export const dateFormatter = (interval, longForm) => {
|
||||||
|
return function(isoDate, _index, _ticks) {
|
||||||
|
let date = parseUTCDate(isoDate)
|
||||||
|
|
||||||
|
if (interval === 'month') {
|
||||||
|
return formatMonthYYYY(date);
|
||||||
|
} else if (interval === 'date') {
|
||||||
|
return formatDay(date);
|
||||||
|
} else if (interval === 'hour') {
|
||||||
|
const parts = isoDate.split(/[^0-9]/);
|
||||||
|
date = new Date(parts[0], parts[1] - 1, parts[2], parts[3], parts[4], parts[5])
|
||||||
|
var hours = date.getHours(); // Not sure why getUTCHours doesn't work here
|
||||||
|
var ampm = hours >= 12 ? 'pm' : 'am';
|
||||||
|
hours = hours % 12;
|
||||||
|
hours = hours ? hours : 12; // the hour '0' should be '12'
|
||||||
|
return hours + ampm;
|
||||||
|
} else if (interval === 'minute') {
|
||||||
|
if (longForm) {
|
||||||
|
const minutesAgo = Math.abs(isoDate)
|
||||||
|
return minutesAgo === 1 ? '1 minute ago' : minutesAgo + ' minutes ago'
|
||||||
|
} else {
|
||||||
|
return isoDate + 'm'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GraphTooltip = (graphData, metric) => {
|
||||||
|
return (context) => {
|
||||||
|
const tooltipModel = context.tooltip;
|
||||||
|
const offset = document.getElementById("main-graph-canvas").getBoundingClientRect()
|
||||||
|
|
||||||
|
// Tooltip Element
|
||||||
|
let tooltipEl = document.getElementById('chartjs-tooltip');
|
||||||
|
|
||||||
|
// Create element on first render
|
||||||
|
if (!tooltipEl) {
|
||||||
|
tooltipEl = document.createElement('div');
|
||||||
|
tooltipEl.id = 'chartjs-tooltip';
|
||||||
|
tooltipEl.style.display = 'none';
|
||||||
|
tooltipEl.style.opacity = 0;
|
||||||
|
document.body.appendChild(tooltipEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tooltipEl && offset && window.innerWidth < 768) {
|
||||||
|
tooltipEl.style.top = offset.y + offset.height + window.scrollY + 15 + 'px'
|
||||||
|
tooltipEl.style.left = offset.x + 'px'
|
||||||
|
tooltipEl.style.right = null;
|
||||||
|
tooltipEl.style.opacity = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop if no tooltip showing
|
||||||
|
if (tooltipModel.opacity === 0) {
|
||||||
|
tooltipEl.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBody(bodyItem) {
|
||||||
|
return bodyItem.lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLabel(label, prev_label) {
|
||||||
|
const formattedLabel = dateFormatter(graphData.interval, true)(label)
|
||||||
|
const prev_formattedLabel = prev_label && dateFormatter(graphData.interval, true)(prev_label)
|
||||||
|
|
||||||
|
if (graphData.interval === 'month') {
|
||||||
|
return !prev_label ? formattedLabel : prev_formattedLabel
|
||||||
|
}
|
||||||
|
|
||||||
|
if (graphData.interval === 'date') {
|
||||||
|
return !prev_label ? formattedLabel : prev_formattedLabel
|
||||||
|
}
|
||||||
|
|
||||||
|
if (graphData.interval === 'hour') {
|
||||||
|
return !prev_label ? `${dateFormatter("date", true)(label)}, ${formattedLabel}` : `${dateFormatter("date", true)(prev_label)}, ${dateFormatter(graphData.interval, true)(prev_label)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return !prev_label ? formattedLabel : prev_formattedLabel
|
||||||
|
}
|
||||||
|
|
||||||
|
// function renderComparison(change) {
|
||||||
|
// const formattedComparison = numberFormatter(Math.abs(change))
|
||||||
|
|
||||||
|
// if (change > 0) {
|
||||||
|
// return `<span class='text-green-500 font-bold'>${formattedComparison}%</span>`
|
||||||
|
// }
|
||||||
|
// if (change < 0) {
|
||||||
|
// return `<span class='text-red-400 font-bold'>${formattedComparison}%</span>`
|
||||||
|
// }
|
||||||
|
// if (change === 0) {
|
||||||
|
// return `<span class='font-bold'>0%</span>`
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// Set Tooltip Body
|
||||||
|
if (tooltipModel.body) {
|
||||||
|
var bodyLines = tooltipModel.body.map(getBody);
|
||||||
|
|
||||||
|
// Remove duplicated line on overlap between dashed and normal
|
||||||
|
if (bodyLines.length == 3) {
|
||||||
|
bodyLines[1] = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = tooltipModel.dataPoints[0]
|
||||||
|
const label = graphData.labels[data.dataIndex]
|
||||||
|
const point = data.raw || 0
|
||||||
|
|
||||||
|
// const prev_data = tooltipModel.dataPoints.slice(-1)[0]
|
||||||
|
// const prev_label = graphData.prev_labels && graphData.prev_labels[prev_data.dataIndex]
|
||||||
|
// const prev_point = prev_data.raw || 0
|
||||||
|
// const pct_change = point === prev_point ? 0 : prev_point === 0 ? 100 : Math.round(((point - prev_point) / prev_point * 100).toFixed(1))
|
||||||
|
|
||||||
|
let innerHtml = `
|
||||||
|
<div class='text-gray-100 flex flex-col'>
|
||||||
|
<div class='flex justify-between items-center'>
|
||||||
|
<span class='font-bold mr-4 text-lg'>${METRIC_LABELS[metric]}</span>
|
||||||
|
</div>
|
||||||
|
<div class='flex flex-col'>
|
||||||
|
<div class='flex flex-row justify-between items-center'>
|
||||||
|
<span class='flex items-center mr-4'>
|
||||||
|
<div class='w-3 h-3 mr-1 rounded-full' style='background-color: rgba(101,116,205)'></div>
|
||||||
|
<span>${renderLabel(label)}</span>
|
||||||
|
</span>
|
||||||
|
<span>${METRIC_FORMATTER[metric](point)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class='font-bold text-'>${graphData.interval === 'month' ? 'Click to view month' : graphData.interval === 'date' ? 'Click to view day' : ''}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
tooltipEl.innerHTML = innerHtml;
|
||||||
|
}
|
||||||
|
tooltipEl.style.display = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const buildDataSet = (plot, present_index, ctx, label, isPrevious) => {
|
||||||
|
var gradient = ctx.createLinearGradient(0, 0, 0, 300);
|
||||||
|
var prev_gradient = ctx.createLinearGradient(0, 0, 0, 300);
|
||||||
|
gradient.addColorStop(0, 'rgba(101,116,205, 0.2)');
|
||||||
|
gradient.addColorStop(1, 'rgba(101,116,205, 0)');
|
||||||
|
prev_gradient.addColorStop(0, 'rgba(101,116,205, 0.075)');
|
||||||
|
prev_gradient.addColorStop(1, 'rgba(101,116,205, 0)');
|
||||||
|
|
||||||
|
if (!isPrevious) {
|
||||||
|
if (present_index) {
|
||||||
|
var dashedPart = plot.slice(present_index - 1, present_index + 1);
|
||||||
|
var dashedPlot = (new Array(present_index - 1)).concat(dashedPart)
|
||||||
|
const _plot = [...plot]
|
||||||
|
for (var i = present_index; i < _plot.length; i++) {
|
||||||
|
_plot[i] = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return [{
|
||||||
|
label,
|
||||||
|
data: _plot,
|
||||||
|
borderWidth: 3,
|
||||||
|
borderColor: 'rgba(101,116,205)',
|
||||||
|
pointBackgroundColor: 'rgba(101,116,205)',
|
||||||
|
pointHoverBackgroundColor: 'rgba(71, 87, 193)',
|
||||||
|
pointBorderColor: 'transparent',
|
||||||
|
pointHoverRadius: 4,
|
||||||
|
backgroundColor: gradient,
|
||||||
|
fill: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label,
|
||||||
|
data: dashedPlot,
|
||||||
|
borderWidth: 3,
|
||||||
|
borderDash: [3, 3],
|
||||||
|
borderColor: 'rgba(101,116,205)',
|
||||||
|
pointHoverBackgroundColor: 'rgba(71, 87, 193)',
|
||||||
|
pointBorderColor: 'transparent',
|
||||||
|
pointHoverRadius: 4,
|
||||||
|
backgroundColor: gradient,
|
||||||
|
fill: true,
|
||||||
|
}]
|
||||||
|
} else {
|
||||||
|
return [{
|
||||||
|
label,
|
||||||
|
data: plot,
|
||||||
|
borderWidth: 3,
|
||||||
|
borderColor: 'rgba(101,116,205)',
|
||||||
|
pointHoverBackgroundColor: 'rgba(71, 87, 193)',
|
||||||
|
pointBorderColor: 'transparent',
|
||||||
|
pointHoverRadius: 4,
|
||||||
|
backgroundColor: gradient,
|
||||||
|
fill: true,
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return [{
|
||||||
|
label,
|
||||||
|
data: plot,
|
||||||
|
borderWidth: 2,
|
||||||
|
// borderDash: [10, 1],
|
||||||
|
borderColor: 'rgba(166,187,210,0.5)',
|
||||||
|
pointHoverBackgroundColor: 'rgba(166,187,210,0.8)',
|
||||||
|
pointBorderColor: 'transparent',
|
||||||
|
pointHoverBorderColor: 'transparent',
|
||||||
|
pointHoverRadius: 4,
|
||||||
|
backgroundColor: prev_gradient,
|
||||||
|
fill: true,
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}
|
97
assets/js/dashboard/stats/graph/top-stats.js
Normal file
97
assets/js/dashboard/stats/graph/top-stats.js
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
import React from "react";
|
||||||
|
import numberFormatter, {durationFormatter} from '../../util/number-formatter'
|
||||||
|
import { METRIC_MAPPING, METRIC_LABELS } from './visitor-graph'
|
||||||
|
|
||||||
|
export default class TopStats extends React.Component {
|
||||||
|
renderComparison(name, comparison) {
|
||||||
|
const formattedComparison = numberFormatter(Math.abs(comparison))
|
||||||
|
|
||||||
|
if (comparison > 0) {
|
||||||
|
const color = name === 'Bounce rate' ? 'text-red-400' : 'text-green-500'
|
||||||
|
return <span className="text-xs dark:text-gray-100"><span className={color + ' font-bold'}>↑</span> {formattedComparison}%</span>
|
||||||
|
} else if (comparison < 0) {
|
||||||
|
const color = name === 'Bounce rate' ? 'text-green-500' : 'text-red-400'
|
||||||
|
return <span className="text-xs dark:text-gray-100"><span className={color + ' font-bold'}>↓</span> {formattedComparison}%</span>
|
||||||
|
} else if (comparison === 0) {
|
||||||
|
return <span className="text-xs text-gray-700 dark:text-gray-300">〰 N/A</span>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
topStatNumberShort(stat) {
|
||||||
|
if (['visit duration', 'time on page'].includes(stat.name.toLowerCase())) {
|
||||||
|
return durationFormatter(stat.value)
|
||||||
|
} else if (['bounce rate', 'conversion rate'].includes(stat.name.toLowerCase())) {
|
||||||
|
return stat.value + '%'
|
||||||
|
} else {
|
||||||
|
return numberFormatter(stat.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
topStatTooltip(stat) {
|
||||||
|
if (['visit duration', 'time on page', 'bounce rate', 'conversion rate'].includes(stat.name.toLowerCase())) {
|
||||||
|
return null
|
||||||
|
} else {
|
||||||
|
let name = stat.name.toLowerCase()
|
||||||
|
name = stat.value === 1 ? name.slice(0, -1) : name
|
||||||
|
return stat.value.toLocaleString() + ' ' + name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
titleFor(stat) {
|
||||||
|
if(this.props.metric === METRIC_MAPPING[stat.name]) {
|
||||||
|
return `Hide ${METRIC_LABELS[METRIC_MAPPING[stat.name]].toLowerCase()} from graph`
|
||||||
|
} else {
|
||||||
|
return `Show ${METRIC_LABELS[METRIC_MAPPING[stat.name]].toLowerCase()} on graph`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderStat(stat) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between my-1 whitespace-nowrap">
|
||||||
|
<b className="mr-4 text-xl md:text-2xl dark:text-gray-100" tooltip={this.topStatTooltip(stat)}>{this.topStatNumberShort(stat)}</b>
|
||||||
|
{this.renderComparison(stat.name, stat.change)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { updateMetric, metric, topStatData, query } = this.props
|
||||||
|
|
||||||
|
const stats = topStatData && topStatData.top_stats.map((stat, index) => {
|
||||||
|
let border = index > 0 ? 'lg:border-l border-gray-300' : ''
|
||||||
|
border = index % 2 === 0 ? border + ' border-r lg:border-r-0' : border
|
||||||
|
const isClickable = Object.keys(METRIC_MAPPING).includes(stat.name) && !(query.filters.goal && stat.name === 'Unique visitors')
|
||||||
|
const isSelected = metric === METRIC_MAPPING[stat.name]
|
||||||
|
const [statDisplayName, statExtraName] = stat.name.split(/(\(.+\))/g)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment key={stat.name}>
|
||||||
|
{ isClickable ?
|
||||||
|
(
|
||||||
|
<div className={`px-4 md:px-6 w-1/2 my-4 lg:w-auto group cursor-pointer select-none ${border}`} onClick={() => { updateMetric(METRIC_MAPPING[stat.name]) }} tabIndex={0} title={this.titleFor(stat)}>
|
||||||
|
<div
|
||||||
|
className={`text-xs font-bold tracking-wide text-gray-500 uppercase dark:text-gray-400 whitespace-nowrap flex w-content ${isSelected ? 'text-indigo-700 dark:text-indigo-500 border-indigo-700 dark:border-indigo-500' : 'group-hover:text-indigo-700 dark:group-hover:text-indigo-500'}`}>
|
||||||
|
{statDisplayName}
|
||||||
|
<span className="hidden sm:inline-block ml-1">{statExtraName}</span>
|
||||||
|
</div>
|
||||||
|
{ this.renderStat(stat) }
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={`px-4 md:px-6 w-1/2 my-4 lg:w-auto ${border}`}>
|
||||||
|
<div className='text-xs font-bold tracking-wide text-gray-500 uppercase dark:text-gray-400 whitespace-nowrap flex'>
|
||||||
|
{stat.name}
|
||||||
|
</div>
|
||||||
|
{ this.renderStat(stat) }
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (query && query.period === 'realtime') {
|
||||||
|
stats.push(<div key="dot" className="block pulsating-circle" style={{ left: '125px', top: '52px' }}></div>)
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats
|
||||||
|
}
|
||||||
|
}
|
419
assets/js/dashboard/stats/graph/visitor-graph.js
Normal file
419
assets/js/dashboard/stats/graph/visitor-graph.js
Normal file
@ -0,0 +1,419 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { withRouter, Link } from 'react-router-dom'
|
||||||
|
import Chart from 'chart.js/auto';
|
||||||
|
import { navigateToQuery } from '../../query'
|
||||||
|
import numberFormatter, {durationFormatter} from '../../util/number-formatter'
|
||||||
|
import * as api from '../../api'
|
||||||
|
import * as storage from '../../util/storage'
|
||||||
|
import LazyLoader from '../../components/lazy-loader'
|
||||||
|
import {GraphTooltip, buildDataSet, dateFormatter} from './graph-util';
|
||||||
|
import TopStats from './top-stats';
|
||||||
|
import * as url from '../../util/url'
|
||||||
|
|
||||||
|
export const METRIC_MAPPING = {
|
||||||
|
'Unique visitors (last 30 min)': 'visitors',
|
||||||
|
'Pageviews (last 30 min)': 'pageviews',
|
||||||
|
'Unique visitors': 'visitors',
|
||||||
|
'Visit duration': 'visit_duration',
|
||||||
|
'Total pageviews': 'pageviews',
|
||||||
|
'Bounce rate': 'bounce_rate',
|
||||||
|
'Unique conversions': 'conversions',
|
||||||
|
// 'Time on Page': 'time',
|
||||||
|
// 'Conversion rate': 'conversion_rate',
|
||||||
|
// 'Total conversions': 't_conversions',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const METRIC_LABELS = {
|
||||||
|
'visitors': 'Visitors',
|
||||||
|
'pageviews': 'Pageviews',
|
||||||
|
'bounce_rate': 'Bounce Rate',
|
||||||
|
'visit_duration': 'Visit Duration',
|
||||||
|
'conversions': 'Converted Visitors',
|
||||||
|
// 'time': 'Time on Page',
|
||||||
|
// 'conversion_rate': 'Conversion Rate',
|
||||||
|
// 't_conversions': 'Total Conversions'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const METRIC_FORMATTER = {
|
||||||
|
'visitors': numberFormatter,
|
||||||
|
'pageviews': numberFormatter,
|
||||||
|
'bounce_rate': (number) => (`${number}%`),
|
||||||
|
'visit_duration': durationFormatter,
|
||||||
|
'conversions': numberFormatter,
|
||||||
|
// 'time': durationFormatter,
|
||||||
|
// 'conversion_rate': (number) => (`${Math.max(number, 100)}%`),
|
||||||
|
// 't_conversions': numberFormatter
|
||||||
|
}
|
||||||
|
|
||||||
|
class LineGraph extends React.Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.regenerateChart = this.regenerateChart.bind(this);
|
||||||
|
this.updateWindowDimensions = this.updateWindowDimensions.bind(this);
|
||||||
|
this.state = {
|
||||||
|
exported: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
regenerateChart() {
|
||||||
|
const { graphData, metric } = this.props
|
||||||
|
const graphEl = document.getElementById("main-graph-canvas")
|
||||||
|
this.ctx = graphEl.getContext('2d');
|
||||||
|
const dataSet = buildDataSet(graphData.plot, graphData.present_index, this.ctx, METRIC_LABELS[metric])
|
||||||
|
// const prev_dataSet = graphData.prev_plot && buildDataSet(graphData.prev_plot, false, this.ctx, METRIC_LABELS[metric], true)
|
||||||
|
// const combinedDataSets = comparison.enabled && prev_dataSet ? [...dataSet, ...prev_dataSet] : dataSet;
|
||||||
|
|
||||||
|
return new Chart(this.ctx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: graphData.labels,
|
||||||
|
datasets: dataSet
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
animation: false,
|
||||||
|
plugins: {
|
||||||
|
legend: { display: false },
|
||||||
|
tooltip: {
|
||||||
|
enabled: false,
|
||||||
|
mode: 'index',
|
||||||
|
intersect: false,
|
||||||
|
position: 'average',
|
||||||
|
external: GraphTooltip(graphData, metric)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responsive: true,
|
||||||
|
onResize: this.updateWindowDimensions,
|
||||||
|
elements: { line: { tension: 0 }, point: { radius: 0 } },
|
||||||
|
onClick: this.onClick.bind(this),
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
ticks: {
|
||||||
|
callback: METRIC_FORMATTER[metric],
|
||||||
|
maxTicksLimit: 8,
|
||||||
|
color: this.props.darkTheme ? 'rgb(243, 244, 246)' : undefined
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
zeroLineColor: 'transparent',
|
||||||
|
drawBorder: false,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
x: {
|
||||||
|
grid: { display: false },
|
||||||
|
ticks: {
|
||||||
|
maxTicksLimit: 8,
|
||||||
|
callback: function(val, _index, _ticks) { return dateFormatter(graphData.interval)(this.getLabelForValue(val)) },
|
||||||
|
color: this.props.darkTheme ? 'rgb(243, 244, 246)' : undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
interaction: {
|
||||||
|
mode: 'index',
|
||||||
|
intersect: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
repositionTooltip(e) {
|
||||||
|
const tooltipEl = document.getElementById('chartjs-tooltip');
|
||||||
|
if (tooltipEl && window.innerWidth >= 768) {
|
||||||
|
if (e.clientX > 0.66 * window.innerWidth) {
|
||||||
|
tooltipEl.style.right = (window.innerWidth - e.clientX) + window.pageXOffset + 'px'
|
||||||
|
tooltipEl.style.left = null;
|
||||||
|
} else {
|
||||||
|
tooltipEl.style.right = null;
|
||||||
|
tooltipEl.style.left = e.clientX + window.pageXOffset + 'px'
|
||||||
|
}
|
||||||
|
tooltipEl.style.top = e.clientY + window.pageYOffset + 'px'
|
||||||
|
tooltipEl.style.opacity = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
if (this.props.metric && this.props.graphData) {
|
||||||
|
this.chart = this.regenerateChart();
|
||||||
|
}
|
||||||
|
window.addEventListener('mousemove', this.repositionTooltip);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate(prevProps) {
|
||||||
|
const { graphData, metric, darkTheme } = this.props;
|
||||||
|
const tooltip = document.getElementById('chartjs-tooltip');
|
||||||
|
|
||||||
|
if (metric && graphData && (
|
||||||
|
graphData !== prevProps.graphData ||
|
||||||
|
darkTheme !== prevProps.darkTheme
|
||||||
|
)) {
|
||||||
|
if (this.chart) {
|
||||||
|
this.chart.destroy();
|
||||||
|
}
|
||||||
|
this.chart = this.regenerateChart();
|
||||||
|
this.chart.update();
|
||||||
|
|
||||||
|
if (tooltip) {
|
||||||
|
tooltip.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!graphData || !metric) {
|
||||||
|
if (this.chart) {
|
||||||
|
this.chart.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tooltip) {
|
||||||
|
tooltip.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
// Ensure that the tooltip doesn't hang around when we are loading more data
|
||||||
|
const tooltip = document.getElementById('chartjs-tooltip');
|
||||||
|
if (tooltip) {
|
||||||
|
tooltip.style.opacity = 0;
|
||||||
|
tooltip.style.display = 'none';
|
||||||
|
}
|
||||||
|
window.removeEventListener('mousemove', this.repositionTooltip)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The current ticks' limits are set to treat iPad (regular/Mini/Pro) as a regular screen.
|
||||||
|
* @param {*} chart - The chart instance.
|
||||||
|
* @param {*} dimensions - An object containing the new dimensions *of the chart.*
|
||||||
|
*/
|
||||||
|
updateWindowDimensions(chart, dimensions) {
|
||||||
|
chart.options.scales.x.ticks.maxTicksLimit = dimensions.width < 720 ? 5 : 8
|
||||||
|
chart.options.scales.y.ticks.maxTicksLimit = dimensions.height < 233 ? 3 : 8
|
||||||
|
}
|
||||||
|
|
||||||
|
onClick(e) {
|
||||||
|
const element = this.chart.getElementsAtEventForMode(e, 'index', { intersect: false })[0]
|
||||||
|
const date = this.chart.data.labels[element.index]
|
||||||
|
|
||||||
|
if (this.props.graphData.interval === 'month') {
|
||||||
|
navigateToQuery(
|
||||||
|
this.props.history,
|
||||||
|
this.props.query,
|
||||||
|
{
|
||||||
|
period: 'month',
|
||||||
|
date,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} else if (this.props.graphData.interval === 'date') {
|
||||||
|
navigateToQuery(
|
||||||
|
this.props.history,
|
||||||
|
this.props.query,
|
||||||
|
{
|
||||||
|
period: 'day',
|
||||||
|
date,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pollExportReady() {
|
||||||
|
if (document.cookie.includes('exporting')) {
|
||||||
|
setTimeout(this.pollExportReady.bind(this), 1000);
|
||||||
|
} else {
|
||||||
|
this.setState({exported: false})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadSpinner() {
|
||||||
|
this.setState({exported: true});
|
||||||
|
document.cookie = "exporting=";
|
||||||
|
setTimeout(this.pollExportReady.bind(this), 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadLink() {
|
||||||
|
if (this.props.query.period !== 'realtime') {
|
||||||
|
|
||||||
|
if (this.state.exported) {
|
||||||
|
return (
|
||||||
|
<div className="w-4 h-4 mx-2">
|
||||||
|
<svg className="animate-spin h-4 w-4 text-indigo-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
const endpoint = `/${encodeURIComponent(this.props.site.domain)}/export${api.serializeQuery(this.props.query)}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a className="w-4 h-4 mx-2" href={endpoint} download onClick={this.downloadSpinner.bind(this)}>
|
||||||
|
<svg className="absolute text-gray-700 feather dark:text-gray-300" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
samplingNotice() {
|
||||||
|
const samplePercent = this.props.topStatData && this.props.topStatData.sample_percent
|
||||||
|
|
||||||
|
if (samplePercent < 100) {
|
||||||
|
return (
|
||||||
|
<div tooltip={`Stats based on a ${samplePercent}% sample of all visitors`} className="cursor-pointer w-4 h-4 mx-2">
|
||||||
|
<svg className="absolute w-4 h-4 dark:text-gray-300 text-gray-700" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
importedNotice() {
|
||||||
|
const source = this.props.topStatData.imported_source;
|
||||||
|
|
||||||
|
if (source) {
|
||||||
|
const withImported = this.props.topStatData.with_imported;
|
||||||
|
const strike = withImported ? "" : " line-through"
|
||||||
|
const target = url.setQuery('with_imported', !withImported)
|
||||||
|
const tip = withImported ? "" : "do not ";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link to={target} className="w-4 h-4 mx-2">
|
||||||
|
<div tooltip={`Stats ${tip}include data imported from ${source}.`} className="cursor-pointer w-4 h-4">
|
||||||
|
<svg className="absolute dark:text-gray-300 text-gray-700" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<text x="4" y="18" fontSize="24" fill="currentColor" className={"text-gray-700 dark:text-gray-300" + strike}>{ source[0].toUpperCase() }</text>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { updateMetric, metric, topStatData, query } = this.props
|
||||||
|
const extraClass = this.props.graphData && this.props.graphData.interval === 'hour' ? '' : 'cursor-pointer'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="graph-inner">
|
||||||
|
<div className="flex flex-wrap">
|
||||||
|
<TopStats query={query} metric={metric} updateMetric={updateMetric} topStatData={topStatData}/>
|
||||||
|
</div>
|
||||||
|
<div className="relative px-2">
|
||||||
|
<div className="absolute right-4 -top-10 flex">
|
||||||
|
{this.downloadLink()}
|
||||||
|
{this.samplingNotice()}
|
||||||
|
{ this.importedNotice() }
|
||||||
|
</div>
|
||||||
|
<canvas id="main-graph-canvas" className={'mt-4 select-none ' + extraClass} width="1054" height="342"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const LineGraphWithRouter = withRouter(LineGraph)
|
||||||
|
|
||||||
|
export default class VisitorGraph extends React.Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props)
|
||||||
|
this.state = {
|
||||||
|
loading: 2,
|
||||||
|
metric: storage.getItem(`metric__${this.props.site.domain}`) || 'visitors'
|
||||||
|
}
|
||||||
|
this.onVisible = this.onVisible.bind(this)
|
||||||
|
this.updateMetric = this.updateMetric.bind(this)
|
||||||
|
this.fetchTopStatData = this.fetchTopStatData.bind(this)
|
||||||
|
this.fetchGraphData = this.fetchGraphData.bind(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
onVisible() {
|
||||||
|
this.fetchGraphData()
|
||||||
|
this.fetchTopStatData()
|
||||||
|
if (this.props.timer) {
|
||||||
|
this.props.timer.onTick(this.fetchGraphData)
|
||||||
|
this.props.timer.onTick(this.fetchTopStatData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate(prevProps, prevState) {
|
||||||
|
const { metric, topStatData } = this.state;
|
||||||
|
|
||||||
|
if (this.props.query !== prevProps.query) {
|
||||||
|
this.setState({ loading: 3, graphData: null, topStatData: null })
|
||||||
|
this.fetchGraphData()
|
||||||
|
this.fetchTopStatData()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (metric !== prevState.metric) {
|
||||||
|
this.setState({loading: 1, graphData: null})
|
||||||
|
this.fetchGraphData()
|
||||||
|
}
|
||||||
|
|
||||||
|
const savedMetric = storage.getItem(`metric__${this.props.site.domain}`)
|
||||||
|
const topStatLabels = topStatData && topStatData.top_stats.map(({ name }) => METRIC_MAPPING[name]).filter(name => name)
|
||||||
|
const prevTopStatLabels = prevState.topStatData && prevState.topStatData.top_stats.map(({ name }) => METRIC_MAPPING[name]).filter(name => name)
|
||||||
|
if (topStatLabels && `${topStatLabels}` !== `${prevTopStatLabels}`) {
|
||||||
|
if (!topStatLabels.includes(savedMetric) && savedMetric !== "") {
|
||||||
|
if (this.props.query.filters.goal && metric !== 'conversions') {
|
||||||
|
this.setState({ metric: 'conversions' })
|
||||||
|
} else {
|
||||||
|
this.setState({ metric: topStatLabels[0] })
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.setState({ metric: savedMetric })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateMetric(newMetric) {
|
||||||
|
if (newMetric === this.state.metric) {
|
||||||
|
storage.setItem(`metric__${this.props.site.domain}`, "")
|
||||||
|
this.setState({ metric: "" })
|
||||||
|
} else {
|
||||||
|
storage.setItem(`metric__${this.props.site.domain}`, newMetric)
|
||||||
|
this.setState({ metric: newMetric })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchGraphData() {
|
||||||
|
if (this.state.metric) {
|
||||||
|
api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/main-graph`, this.props.query, {metric: this.state.metric || 'none'})
|
||||||
|
.then((res) => {
|
||||||
|
this.setState((state) => ({ loading: state.loading-2, graphData: res }))
|
||||||
|
return res
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
this.setState((state) => ({ loading: state.loading-2, graphData: null }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchTopStatData() {
|
||||||
|
api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/top-stats`, this.props.query)
|
||||||
|
.then((res) => {
|
||||||
|
this.setState((state) => ({ loading: state.loading-1, topStatData: res }))
|
||||||
|
return res
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
renderInner() {
|
||||||
|
const { query, site } = this.props;
|
||||||
|
const { graphData, metric, topStatData, loading } = this.state;
|
||||||
|
|
||||||
|
const theme = document.querySelector('html').classList.contains('dark') || false
|
||||||
|
|
||||||
|
if ((loading <= 1 && topStatData) || (topStatData && graphData)) {
|
||||||
|
return (
|
||||||
|
<LineGraphWithRouter graphData={graphData} topStatData={topStatData} site={site} query={query} darkTheme={theme} metric={metric} updateMetric={this.updateMetric} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const {metric, topStatData, graphData} = this.state
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LazyLoader onVisible={this.onVisible}>
|
||||||
|
<div className={`relative w-full mt-2 bg-white rounded shadow-xl dark:bg-gray-825 transition-padding ease-in-out duration-150 ${metric ? 'main-graph' : 'top-stats-only'}`}>
|
||||||
|
{this.state.loading > 0 && <div className="graph-inner"><div className={`${topStatData && !graphData ? 'pt-52 sm:pt-56 md:pt-60' : metric ? 'pt-32 sm:pt-36 md:pt-48' : 'pt-16 sm:pt-14 md:pt-18 lg:pt-5'} mx-auto loading`}><div></div></div></div>}
|
||||||
|
{this.renderInner()}
|
||||||
|
</div>
|
||||||
|
</LazyLoader>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
@ -1,428 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { withRouter, Link } from 'react-router-dom'
|
|
||||||
import Chart from 'chart.js/auto';
|
|
||||||
import { navigateToQuery } from '../query'
|
|
||||||
import numberFormatter, {durationFormatter} from '../util/number-formatter'
|
|
||||||
import * as api from '../api'
|
|
||||||
import LazyLoader from '../components/lazy-loader'
|
|
||||||
import * as url from '../util/url'
|
|
||||||
|
|
||||||
function buildDataSet(plot, present_index, ctx, label) {
|
|
||||||
var gradient = ctx.createLinearGradient(0, 0, 0, 300);
|
|
||||||
gradient.addColorStop(0, 'rgba(101,116,205, 0.2)');
|
|
||||||
gradient.addColorStop(1, 'rgba(101,116,205, 0)');
|
|
||||||
|
|
||||||
if (present_index) {
|
|
||||||
var dashedPart = plot.slice(present_index - 1, present_index + 1);
|
|
||||||
var dashedPlot = (new Array(present_index - 1)).concat(dashedPart)
|
|
||||||
for(var i = present_index; i < plot.length; i++) {
|
|
||||||
plot[i] = undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
return [{
|
|
||||||
label: label,
|
|
||||||
data: plot,
|
|
||||||
borderWidth: 3,
|
|
||||||
borderColor: 'rgba(101,116,205)',
|
|
||||||
pointBackgroundColor: 'rgba(101,116,205)',
|
|
||||||
backgroundColor: gradient,
|
|
||||||
fill: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: label,
|
|
||||||
data: dashedPlot,
|
|
||||||
borderWidth: 3,
|
|
||||||
borderDash: [5, 10],
|
|
||||||
borderColor: 'rgba(101,116,205)',
|
|
||||||
pointBackgroundColor: 'rgba(101,116,205)',
|
|
||||||
backgroundColor: gradient,
|
|
||||||
fill: true
|
|
||||||
}]
|
|
||||||
} else {
|
|
||||||
return [{
|
|
||||||
label: label,
|
|
||||||
data: plot,
|
|
||||||
borderWidth: 3,
|
|
||||||
borderColor: 'rgba(101,116,205)',
|
|
||||||
pointBackgroundColor: 'rgba(101,116,205)',
|
|
||||||
backgroundColor: gradient,
|
|
||||||
fill: true
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
import {parseUTCDate, formatMonthYYYY, formatDay} from '../util/date'
|
|
||||||
|
|
||||||
function dateFormatter(interval, longForm) {
|
|
||||||
return function(isoDate, _index, _ticks) {
|
|
||||||
let date = parseUTCDate(isoDate)
|
|
||||||
|
|
||||||
if (interval === 'month') {
|
|
||||||
return formatMonthYYYY(date)
|
|
||||||
} else if (interval === 'date') {
|
|
||||||
return formatDay(date)
|
|
||||||
} else if (interval === 'hour') {
|
|
||||||
const parts = isoDate.split(/[^0-9]/);
|
|
||||||
date = new Date(parts[0],parts[1]-1,parts[2],parts[3],parts[4],parts[5])
|
|
||||||
var hours = date.getHours(); // Not sure why getUTCHours doesn't work here
|
|
||||||
var ampm = hours >= 12 ? 'pm' : 'am';
|
|
||||||
hours = hours % 12;
|
|
||||||
hours = hours ? hours : 12; // the hour '0' should be '12'
|
|
||||||
return hours + ampm;
|
|
||||||
} else if (interval === 'minute') {
|
|
||||||
if (longForm) {
|
|
||||||
const minutesAgo = Math.abs(isoDate)
|
|
||||||
return minutesAgo === 1 ? '1 minute ago' : minutesAgo + ' minutes ago'
|
|
||||||
} else {
|
|
||||||
return isoDate + 'm'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class LineGraph extends React.Component {
|
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
this.regenerateChart = this.regenerateChart.bind(this);
|
|
||||||
this.updateWindowDimensions = this.updateWindowDimensions.bind(this);
|
|
||||||
this.state = {
|
|
||||||
exported: false
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
regenerateChart() {
|
|
||||||
const {graphData} = this.props
|
|
||||||
this.ctx = document.getElementById("main-graph-canvas").getContext('2d');
|
|
||||||
const label = this.props.query.filters.goal ? 'Converted visitors' : graphData.interval === 'minute' ? 'Pageviews' : 'Visitors'
|
|
||||||
const dataSet = buildDataSet(graphData.plot, graphData.present_index, this.ctx, label)
|
|
||||||
|
|
||||||
return new Chart(this.ctx, {
|
|
||||||
type: 'line',
|
|
||||||
data: {
|
|
||||||
labels: graphData.labels,
|
|
||||||
datasets: dataSet
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
animation: false,
|
|
||||||
plugins: {
|
|
||||||
legend: {display: false},
|
|
||||||
tooltip: {
|
|
||||||
mode: 'index',
|
|
||||||
intersect: false,
|
|
||||||
titleFont: {size: 18},
|
|
||||||
footerFont: {size: 14},
|
|
||||||
bodyFont: {size: 14},
|
|
||||||
backgroundColor: 'rgba(25, 30, 56)',
|
|
||||||
titleMarginBottom: 8,
|
|
||||||
bodySpacing: 6,
|
|
||||||
footerMarginTop: 8,
|
|
||||||
padding: {x: 10, y: 10},
|
|
||||||
multiKeyBackground: 'none',
|
|
||||||
callbacks: {
|
|
||||||
title: function(dataPoints) {
|
|
||||||
const data = dataPoints[0]
|
|
||||||
return dateFormatter(graphData.interval, true)(data.label)
|
|
||||||
},
|
|
||||||
beforeBody: function() {
|
|
||||||
this.drawnLabels = {}
|
|
||||||
},
|
|
||||||
label: function(item) {
|
|
||||||
const dataset = item.dataset
|
|
||||||
if (!this.drawnLabels[dataset.label]) {
|
|
||||||
this.drawnLabels[dataset.label] = true
|
|
||||||
const pluralizedLabel = item.formattedValue === "1" ? dataset.label.slice(0, -1) : dataset.label
|
|
||||||
return ` ${item.formattedValue} ${pluralizedLabel}`
|
|
||||||
}
|
|
||||||
},
|
|
||||||
footer: function(_dataPoints) {
|
|
||||||
if (graphData.interval === 'month') {
|
|
||||||
return 'Click to view month'
|
|
||||||
} else if (graphData.interval === 'date') {
|
|
||||||
return 'Click to view day'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
responsive: true,
|
|
||||||
onResize: this.updateWindowDimensions,
|
|
||||||
elements: {line: {tension: 0}, point: {radius: 0}},
|
|
||||||
onClick: this.onClick.bind(this),
|
|
||||||
scales: {
|
|
||||||
y: {
|
|
||||||
beginAtZero: true,
|
|
||||||
ticks: {
|
|
||||||
callback: numberFormatter,
|
|
||||||
maxTicksLimit: 8,
|
|
||||||
color: this.props.darkTheme ? 'rgb(243, 244, 246)' : undefined
|
|
||||||
},
|
|
||||||
grid: {
|
|
||||||
zeroLineColor: 'transparent',
|
|
||||||
drawBorder: false,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
x: {
|
|
||||||
grid: {display: false},
|
|
||||||
ticks: {
|
|
||||||
maxTicksLimit: 8,
|
|
||||||
callback: function(val, _index, _ticks) { return dateFormatter(graphData.interval)(this.getLabelForValue(val)) },
|
|
||||||
color: this.props.darkTheme ? 'rgb(243, 244, 246)' : undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidMount() {
|
|
||||||
this.chart = this.regenerateChart();
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidUpdate(prevProps) {
|
|
||||||
if (this.props.graphData !== prevProps.graphData) {
|
|
||||||
const label = this.props.query.filters.goal ? 'Converted visitors' : this.props.graphData.interval === 'minute' ? 'Pageviews' : 'Visitors'
|
|
||||||
const newDataset = buildDataSet(this.props.graphData.plot, this.props.graphData.present_index, this.ctx, label)
|
|
||||||
|
|
||||||
for (let i = 0; i < newDataset[0].data.length; i++) {
|
|
||||||
this.chart.data.datasets[0].data[i] = newDataset[0].data[i]
|
|
||||||
}
|
|
||||||
|
|
||||||
this.chart.update()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The current ticks' limits are set to treat iPad (regular/Mini/Pro) as a regular screen.
|
|
||||||
* @param {*} chart - The chart instance.
|
|
||||||
* @param {*} dimensions - An object containing the new dimensions *of the chart.*
|
|
||||||
*/
|
|
||||||
updateWindowDimensions(chart, dimensions) {
|
|
||||||
chart.options.scales.x.ticks.maxTicksLimit = dimensions.width < 720 ? 5 : 8
|
|
||||||
chart.options.scales.y.ticks.maxTicksLimit = dimensions.height < 233 ? 3 : 8
|
|
||||||
}
|
|
||||||
|
|
||||||
onClick(e) {
|
|
||||||
const element = this.chart.getElementsAtEventForMode(e, 'index', {intersect: false})[0]
|
|
||||||
const date = this.chart.data.labels[element.index]
|
|
||||||
|
|
||||||
if (this.props.graphData.interval === 'month') {
|
|
||||||
navigateToQuery(
|
|
||||||
this.props.history,
|
|
||||||
this.props.query,
|
|
||||||
{
|
|
||||||
period: 'month',
|
|
||||||
date,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
} else if (this.props.graphData.interval === 'date') {
|
|
||||||
navigateToQuery(
|
|
||||||
this.props.history,
|
|
||||||
this.props.query,
|
|
||||||
{
|
|
||||||
period: 'day',
|
|
||||||
date,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
renderComparison(name, comparison) {
|
|
||||||
const formattedComparison = numberFormatter(Math.abs(comparison))
|
|
||||||
|
|
||||||
if (comparison > 0) {
|
|
||||||
const color = name === 'Bounce rate' ? 'text-red-400' : 'text-green-500'
|
|
||||||
return <span className="text-xs dark:text-gray-100"><span className={color + ' font-bold'}>↑</span> {formattedComparison}%</span>
|
|
||||||
} else if (comparison < 0) {
|
|
||||||
const color = name === 'Bounce rate' ? 'text-green-500' : 'text-red-400'
|
|
||||||
return <span className="text-xs dark:text-gray-100"><span className={color + ' font-bold'}>↓</span> {formattedComparison}%</span>
|
|
||||||
} else if (comparison === 0) {
|
|
||||||
return <span className="text-xs text-gray-700 dark:text-gray-300">〰 N/A</span>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
topStatNumberShort(stat) {
|
|
||||||
if (['visit duration', 'time on page'].includes(stat.name.toLowerCase())) {
|
|
||||||
return durationFormatter(stat.value)
|
|
||||||
} else if (['bounce rate', 'conversion rate'].includes(stat.name.toLowerCase())) {
|
|
||||||
return stat.value + '%'
|
|
||||||
} else {
|
|
||||||
return numberFormatter(stat.value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
topStatTooltip(stat) {
|
|
||||||
if (typeof(stat.value) == 'number') {
|
|
||||||
let name = stat.name.toLowerCase()
|
|
||||||
name = stat.value === 1 ? name.slice(0, -1) : name
|
|
||||||
return stat.value.toLocaleString() + ' ' + name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
renderTopStats() {
|
|
||||||
const {graphData} = this.props
|
|
||||||
const stats = this.props.graphData.top_stats.map((stat, index) => {
|
|
||||||
let border = index > 0 ? 'lg:border-l border-gray-300' : ''
|
|
||||||
border = index % 2 === 0 ? border + ' border-r lg:border-r-0' : border
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`px-8 w-1/2 my-4 lg:w-auto ${border}`} key={stat.name}>
|
|
||||||
<div className="text-xs font-bold tracking-wide text-gray-500 uppercase dark:text-gray-400 whitespace-nowrap">{stat.name}</div>
|
|
||||||
<div className="flex items-center justify-between my-1 whitespace-nowrap">
|
|
||||||
<b className="mr-4 text-xl md:text-2xl dark:text-gray-100" tooltip={this.topStatTooltip(stat)}>{ this.topStatNumberShort(stat) }</b>
|
|
||||||
{this.renderComparison(stat.name, stat.change)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
if (graphData.interval === 'minute') {
|
|
||||||
stats.push(<div key="dot" className="block pulsating-circle" style={{left: '125px', top: '52px'}}></div>)
|
|
||||||
}
|
|
||||||
|
|
||||||
return stats
|
|
||||||
}
|
|
||||||
|
|
||||||
pollExportReady() {
|
|
||||||
if (document.cookie.includes('exporting')) {
|
|
||||||
setTimeout(this.pollExportReady.bind(this), 1000);
|
|
||||||
} else {
|
|
||||||
this.setState({exported: false})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
downloadSpinner() {
|
|
||||||
this.setState({exported: true});
|
|
||||||
document.cookie = "exporting=";
|
|
||||||
setTimeout(this.pollExportReady.bind(this), 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
downloadLink() {
|
|
||||||
if (this.props.query.period !== 'realtime') {
|
|
||||||
|
|
||||||
if (this.state.exported) {
|
|
||||||
return (
|
|
||||||
<div className="flex-auto w-4 h-4">
|
|
||||||
<svg className="animate-spin h-4 w-4 text-indigo-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
|
||||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
|
||||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
const endpoint = `/${encodeURIComponent(this.props.site.domain)}/export${api.serializeQuery(this.props.query)}`
|
|
||||||
|
|
||||||
return (
|
|
||||||
<a className="flex-auto w-4 h-4" href={endpoint} download onClick={this.downloadSpinner.bind(this)}>
|
|
||||||
<svg className="absolute text-gray-700 feather dark:text-gray-300" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>
|
|
||||||
</a>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
samplingNotice() {
|
|
||||||
const samplePercent = this.props.graphData.sample_percent
|
|
||||||
|
|
||||||
if (samplePercent < 100) {
|
|
||||||
return (
|
|
||||||
<div tooltip={`Stats based on a ${samplePercent}% sample of all visitors`} className="cursor-pointer flex-auto w-4 h-4">
|
|
||||||
<svg className="absolute w-4 h-4 text-gray-300 text-gray-700" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
importedNotice() {
|
|
||||||
const source = this.props.graphData.imported_source;
|
|
||||||
|
|
||||||
if (source) {
|
|
||||||
const withImported = this.props.graphData.with_imported;
|
|
||||||
const strike = withImported ? "" : " line-through"
|
|
||||||
const target = url.setQuery('with_imported', !withImported)
|
|
||||||
const tip = withImported ? "" : "do not ";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link to={target} className="w-4 h-4">
|
|
||||||
<div tooltip={`Stats ${tip}include data imported from ${source}.`} className="cursor-pointer flex-auto w-4 h-4">
|
|
||||||
<svg className="absolute text-gray-300 text-gray-700" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
||||||
<text x="4" y="18" fontSize="24" fill="currentColor" className={"text-gray-700 dark:text-gray-300" + strike}>{ source[0].toUpperCase() }</text>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const extraClass = this.props.graphData.interval === 'hour' ? '' : 'cursor-pointer'
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="graph-inner">
|
|
||||||
<div className="flex flex-wrap">
|
|
||||||
{ this.renderTopStats() }
|
|
||||||
</div>
|
|
||||||
<div className="relative px-2">
|
|
||||||
<div className="absolute right-2 -top-5 lg:-top-20 lg:w-6 w-16 lg:h-20 flex lg:flex-col flex-row">
|
|
||||||
{ this.downloadLink() }
|
|
||||||
{ this.samplingNotice() }
|
|
||||||
{ this.importedNotice() }
|
|
||||||
</div>
|
|
||||||
<canvas id="main-graph-canvas" className={'mt-4 select-none ' + extraClass} width="1054" height="342"></canvas>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const LineGraphWithRouter = withRouter(LineGraph)
|
|
||||||
|
|
||||||
export default class VisitorGraph extends React.Component {
|
|
||||||
constructor(props) {
|
|
||||||
super(props)
|
|
||||||
this.state = {loading: true}
|
|
||||||
this.onVisible = this.onVisible.bind(this)
|
|
||||||
}
|
|
||||||
|
|
||||||
onVisible() {
|
|
||||||
this.fetchGraphData()
|
|
||||||
if (this.props.timer) this.props.timer.onTick(this.fetchGraphData.bind(this))
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidUpdate(prevProps) {
|
|
||||||
if (this.props.query !== prevProps.query) {
|
|
||||||
this.setState({loading: true, graphData: null})
|
|
||||||
this.fetchGraphData()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchGraphData() {
|
|
||||||
api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/main-graph`, this.props.query)
|
|
||||||
.then((res) => {
|
|
||||||
this.setState({loading: false, graphData: res})
|
|
||||||
return res
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
renderInner() {
|
|
||||||
if (this.state.graphData) {
|
|
||||||
const theme = document.querySelector('html').classList.contains('dark') || false
|
|
||||||
|
|
||||||
return (
|
|
||||||
<LineGraphWithRouter graphData={this.state.graphData} site={this.props.site} query={this.props.query} darkTheme={theme}/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
return (
|
|
||||||
<LazyLoader onVisible={this.onVisible}>
|
|
||||||
<div className="relative w-full bg-white rounded shadow-xl dark:bg-gray-825 main-graph">
|
|
||||||
{ this.state.loading && <div className="graph-inner"><div className="pt-24 mx-auto loading sm:pt-32 md:pt-48"><div></div></div></div> }
|
|
||||||
{ this.renderInner() }
|
|
||||||
</div>
|
|
||||||
</LazyLoader>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
@ -23,6 +23,7 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
width: {
|
width: {
|
||||||
'31percent': '31%',
|
'31percent': '31%',
|
||||||
|
'content': 'fit-content'
|
||||||
},
|
},
|
||||||
opacity: {
|
opacity: {
|
||||||
'15': '0.15',
|
'15': '0.15',
|
||||||
@ -33,6 +34,9 @@ module.exports = {
|
|||||||
maxWidth: {
|
maxWidth: {
|
||||||
'2xs': '15rem',
|
'2xs': '15rem',
|
||||||
'3xs': '12rem',
|
'3xs': '12rem',
|
||||||
|
},
|
||||||
|
transitionProperty: {
|
||||||
|
'padding': 'padding',
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -9,6 +9,13 @@ defmodule PlausibleWeb.Api.StatsController do
|
|||||||
site = conn.assigns[:site]
|
site = conn.assigns[:site]
|
||||||
query = Query.from(site, params) |> Filters.add_prefix()
|
query = Query.from(site, params) |> Filters.add_prefix()
|
||||||
|
|
||||||
|
selected_metric =
|
||||||
|
if !params["metric"] || params["metric"] == "conversions" do
|
||||||
|
"visitors"
|
||||||
|
else
|
||||||
|
params["metric"]
|
||||||
|
end
|
||||||
|
|
||||||
timeseries_query =
|
timeseries_query =
|
||||||
if query.period == "realtime" do
|
if query.period == "realtime" do
|
||||||
%Query{query | period: "30m"}
|
%Query{query | period: "30m"}
|
||||||
@ -16,11 +23,12 @@ defmodule PlausibleWeb.Api.StatsController do
|
|||||||
query
|
query
|
||||||
end
|
end
|
||||||
|
|
||||||
timeseries = Task.async(fn -> Stats.timeseries(site, timeseries_query, [:visitors]) end)
|
timeseries_result =
|
||||||
{top_stats, sample_percent} = fetch_top_stats(site, query)
|
Stats.timeseries(site, timeseries_query, [String.to_existing_atom(selected_metric)])
|
||||||
|
|
||||||
|
plot =
|
||||||
|
Enum.map(timeseries_result, fn row -> row[String.to_existing_atom(selected_metric)] || 0 end)
|
||||||
|
|
||||||
timeseries_result = Task.await(timeseries)
|
|
||||||
plot = Enum.map(timeseries_result, fn row -> row[:visitors] end)
|
|
||||||
labels = Enum.map(timeseries_result, fn row -> row[:date] end)
|
labels = Enum.map(timeseries_result, fn row -> row[:date] end)
|
||||||
present_index = present_index_for(site, query, labels)
|
present_index = present_index_for(site, query, labels)
|
||||||
|
|
||||||
@ -28,6 +36,19 @@ defmodule PlausibleWeb.Api.StatsController do
|
|||||||
plot: plot,
|
plot: plot,
|
||||||
labels: labels,
|
labels: labels,
|
||||||
present_index: present_index,
|
present_index: present_index,
|
||||||
|
interval: query.interval,
|
||||||
|
with_imported: query.include_imported,
|
||||||
|
imported_source: site.imported_data && site.imported_data.source
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
def top_stats(conn, params) do
|
||||||
|
site = conn.assigns[:site]
|
||||||
|
query = Query.from(site, params) |> Filters.add_prefix()
|
||||||
|
|
||||||
|
{top_stats, sample_percent} = fetch_top_stats(site, query)
|
||||||
|
|
||||||
|
json(conn, %{
|
||||||
top_stats: top_stats,
|
top_stats: top_stats,
|
||||||
interval: query.interval,
|
interval: query.interval,
|
||||||
sample_percent: sample_percent,
|
sample_percent: sample_percent,
|
||||||
|
@ -52,6 +52,7 @@ defmodule PlausibleWeb.Router do
|
|||||||
|
|
||||||
get "/:domain/current-visitors", StatsController, :current_visitors
|
get "/:domain/current-visitors", StatsController, :current_visitors
|
||||||
get "/:domain/main-graph", StatsController, :main_graph
|
get "/:domain/main-graph", StatsController, :main_graph
|
||||||
|
get "/:domain/top-stats", StatsController, :top_stats
|
||||||
get "/:domain/sources", StatsController, :sources
|
get "/:domain/sources", StatsController, :sources
|
||||||
get "/:domain/utm_mediums", StatsController, :utm_mediums
|
get "/:domain/utm_mediums", StatsController, :utm_mediums
|
||||||
get "/:domain/utm_sources", StatsController, :utm_sources
|
get "/:domain/utm_sources", StatsController, :utm_sources
|
||||||
|
@ -628,7 +628,7 @@ defmodule Plausible.ImportedTest do
|
|||||||
conn =
|
conn =
|
||||||
get(
|
get(
|
||||||
conn,
|
conn,
|
||||||
"/api/stats/#{site.domain}/main-graph?period=month&date=2021-01-01&with_imported=true"
|
"/api/stats/#{site.domain}/top-stats?period=month&date=2021-01-01&with_imported=true"
|
||||||
)
|
)
|
||||||
|
|
||||||
assert %{"top_stats" => top_stats} = json_response(conn, 200)
|
assert %{"top_stats" => top_stats} = json_response(conn, 200)
|
||||||
|
@ -32,7 +32,7 @@ defmodule PlausibleWeb.Api.StatsController.AuthorizationTest do
|
|||||||
|
|
||||||
test "Sends 404 Not found for a site that doesn't exist", %{conn: conn} do
|
test "Sends 404 Not found for a site that doesn't exist", %{conn: conn} do
|
||||||
conn = init_session(conn)
|
conn = init_session(conn)
|
||||||
conn = get(conn, "/api/stats/fake-site.com/main-graph")
|
conn = get(conn, "/api/stats/fake-site.com/main-graph/")
|
||||||
|
|
||||||
assert conn.status == 404
|
assert conn.status == 404
|
||||||
end
|
end
|
||||||
|
@ -11,7 +11,7 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do
|
|||||||
build(:pageview, timestamp: relative_time(minutes: -5))
|
build(:pageview, timestamp: relative_time(minutes: -5))
|
||||||
])
|
])
|
||||||
|
|
||||||
conn = get(conn, "/api/stats/#{site.domain}/main-graph?period=realtime")
|
conn = get(conn, "/api/stats/#{site.domain}/main-graph?period=realtime&metric=pageviews")
|
||||||
|
|
||||||
assert %{"plot" => plot} = json_response(conn, 200)
|
assert %{"plot" => plot} = json_response(conn, 200)
|
||||||
|
|
||||||
@ -25,7 +25,11 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do
|
|||||||
build(:pageview, timestamp: ~N[2021-01-01 23:00:00])
|
build(:pageview, timestamp: ~N[2021-01-01 23:00:00])
|
||||||
])
|
])
|
||||||
|
|
||||||
conn = get(conn, "/api/stats/#{site.domain}/main-graph?period=day&date=2021-01-01")
|
conn =
|
||||||
|
get(
|
||||||
|
conn,
|
||||||
|
"/api/stats/#{site.domain}/main-graph?period=day&date=2021-01-01&metric=pageviews"
|
||||||
|
)
|
||||||
|
|
||||||
assert %{"plot" => plot} = json_response(conn, 200)
|
assert %{"plot" => plot} = json_response(conn, 200)
|
||||||
|
|
||||||
@ -43,7 +47,11 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do
|
|||||||
build(:pageview, timestamp: ~N[2021-01-01 00:00:00])
|
build(:pageview, timestamp: ~N[2021-01-01 00:00:00])
|
||||||
])
|
])
|
||||||
|
|
||||||
conn = get(conn, "/api/stats/#{site.domain}/main-graph?period=day&date=2021-01-01")
|
conn =
|
||||||
|
get(
|
||||||
|
conn,
|
||||||
|
"/api/stats/#{site.domain}/main-graph?period=day&date=2021-01-01&metric=visitors"
|
||||||
|
)
|
||||||
|
|
||||||
assert %{"plot" => plot} = json_response(conn, 200)
|
assert %{"plot" => plot} = json_response(conn, 200)
|
||||||
|
|
||||||
@ -59,7 +67,11 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do
|
|||||||
build(:pageview, timestamp: ~N[2021-01-31 00:00:00])
|
build(:pageview, timestamp: ~N[2021-01-31 00:00:00])
|
||||||
])
|
])
|
||||||
|
|
||||||
conn = get(conn, "/api/stats/#{site.domain}/main-graph?period=month&date=2021-01-01")
|
conn =
|
||||||
|
get(
|
||||||
|
conn,
|
||||||
|
"/api/stats/#{site.domain}/main-graph?period=month&date=2021-01-01&metric=visitors"
|
||||||
|
)
|
||||||
|
|
||||||
assert %{"plot" => plot} = json_response(conn, 200)
|
assert %{"plot" => plot} = json_response(conn, 200)
|
||||||
|
|
||||||
@ -207,11 +219,11 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "GET /api/stats/main-graph - labels" do
|
describe "GET /api/stats/main-graph - default labels" do
|
||||||
setup [:create_user, :log_in, :create_site]
|
setup [:create_user, :log_in, :create_site]
|
||||||
|
|
||||||
test "shows last 30 days", %{conn: conn, site: site} do
|
test "shows last 30 days", %{conn: conn, site: site} do
|
||||||
conn = get(conn, "/api/stats/#{site.domain}/main-graph?period=30d")
|
conn = get(conn, "/api/stats/#{site.domain}/main-graph?period=30d&metric=visitors")
|
||||||
assert %{"labels" => labels} = json_response(conn, 200)
|
assert %{"labels" => labels} = json_response(conn, 200)
|
||||||
|
|
||||||
{:ok, first} = Timex.today() |> Timex.shift(days: -30) |> Timex.format("{ISOdate}")
|
{:ok, first} = Timex.today() |> Timex.shift(days: -30) |> Timex.format("{ISOdate}")
|
||||||
@ -221,7 +233,7 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do
|
|||||||
end
|
end
|
||||||
|
|
||||||
test "shows last 7 days", %{conn: conn, site: site} do
|
test "shows last 7 days", %{conn: conn, site: site} do
|
||||||
conn = get(conn, "/api/stats/#{site.domain}/main-graph?period=7d")
|
conn = get(conn, "/api/stats/#{site.domain}/main-graph?period=7d&metric=visitors")
|
||||||
assert %{"labels" => labels} = json_response(conn, 200)
|
assert %{"labels" => labels} = json_response(conn, 200)
|
||||||
|
|
||||||
{:ok, first} = Timex.today() |> Timex.shift(days: -6) |> Timex.format("{ISOdate}")
|
{:ok, first} = Timex.today() |> Timex.shift(days: -6) |> Timex.format("{ISOdate}")
|
||||||
@ -231,324 +243,82 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "GET /api/stats/main-graph - top stats" do
|
describe "GET /api/stats/main-graph - pageviews plot" do
|
||||||
setup [:create_user, :log_in, :create_new_site]
|
setup [:create_user, :log_in, :create_new_site]
|
||||||
|
|
||||||
test "counts distinct user ids", %{conn: conn, site: site} do
|
test "displays pageviews for a month", %{conn: conn, site: site} do
|
||||||
populate_stats(site, [
|
populate_stats(site, [
|
||||||
build(:pageview, user_id: @user_id),
|
build(:pageview, timestamp: ~N[2021-01-01 00:00:00]),
|
||||||
build(:pageview, user_id: @user_id),
|
build(:pageview, timestamp: ~N[2021-01-01 00:00:00]),
|
||||||
build(:pageview)
|
build(:pageview, timestamp: ~N[2021-01-31 00:00:00])
|
||||||
])
|
])
|
||||||
|
|
||||||
conn = get(conn, "/api/stats/#{site.domain}/main-graph?period=day")
|
|
||||||
|
|
||||||
res = json_response(conn, 200)
|
|
||||||
assert %{"name" => "Unique visitors", "value" => 2, "change" => 100} in res["top_stats"]
|
|
||||||
end
|
|
||||||
|
|
||||||
test "counts total pageviews", %{conn: conn, site: site} do
|
|
||||||
populate_stats(site, [
|
|
||||||
build(:pageview, user_id: @user_id),
|
|
||||||
build(:pageview, user_id: @user_id),
|
|
||||||
build(:pageview)
|
|
||||||
])
|
|
||||||
|
|
||||||
conn = get(conn, "/api/stats/#{site.domain}/main-graph?period=day")
|
|
||||||
|
|
||||||
res = json_response(conn, 200)
|
|
||||||
assert %{"name" => "Total pageviews", "value" => 3, "change" => 100} in res["top_stats"]
|
|
||||||
end
|
|
||||||
|
|
||||||
test "calculates bounce rate", %{conn: conn, site: site} do
|
|
||||||
populate_stats(site, [
|
|
||||||
build(:pageview, user_id: @user_id),
|
|
||||||
build(:pageview, user_id: @user_id),
|
|
||||||
build(:pageview)
|
|
||||||
])
|
|
||||||
|
|
||||||
conn = get(conn, "/api/stats/#{site.domain}/main-graph?period=day")
|
|
||||||
|
|
||||||
res = json_response(conn, 200)
|
|
||||||
assert %{"name" => "Bounce rate", "value" => 50, "change" => nil} in res["top_stats"]
|
|
||||||
end
|
|
||||||
|
|
||||||
test "calculates average visit duration", %{conn: conn, site: site} do
|
|
||||||
populate_stats(site, [
|
|
||||||
build(:pageview,
|
|
||||||
user_id: @user_id,
|
|
||||||
timestamp: ~N[2021-01-01 00:00:00]
|
|
||||||
),
|
|
||||||
build(:pageview,
|
|
||||||
user_id: @user_id,
|
|
||||||
timestamp: ~N[2021-01-01 00:15:00]
|
|
||||||
),
|
|
||||||
build(:pageview,
|
|
||||||
timestamp: ~N[2021-01-01 00:15:00]
|
|
||||||
)
|
|
||||||
])
|
|
||||||
|
|
||||||
conn = get(conn, "/api/stats/#{site.domain}/main-graph?period=day&date=2021-01-01")
|
|
||||||
|
|
||||||
res = json_response(conn, 200)
|
|
||||||
assert %{"name" => "Visit duration", "value" => 450, "change" => 100} in res["top_stats"]
|
|
||||||
end
|
|
||||||
|
|
||||||
test "calculates time on page instead when filtered for page", %{conn: conn, site: site} do
|
|
||||||
populate_stats(site, [
|
|
||||||
build(:pageview,
|
|
||||||
pathname: "/pageA",
|
|
||||||
user_id: @user_id,
|
|
||||||
timestamp: ~N[2021-01-01 00:00:00]
|
|
||||||
),
|
|
||||||
build(:pageview,
|
|
||||||
pathname: "/pageB",
|
|
||||||
user_id: @user_id,
|
|
||||||
timestamp: ~N[2021-01-01 00:15:00]
|
|
||||||
),
|
|
||||||
build(:pageview,
|
|
||||||
pathname: "/pageA",
|
|
||||||
timestamp: ~N[2021-01-01 00:15:00]
|
|
||||||
)
|
|
||||||
])
|
|
||||||
|
|
||||||
filters = Jason.encode!(%{page: "/pageA"})
|
|
||||||
|
|
||||||
conn =
|
conn =
|
||||||
get(
|
get(
|
||||||
conn,
|
conn,
|
||||||
"/api/stats/#{site.domain}/main-graph?period=day&date=2021-01-01&filters=#{filters}"
|
"/api/stats/#{site.domain}/main-graph?period=month&date=2021-01-01&metric=pageviews"
|
||||||
)
|
)
|
||||||
|
|
||||||
res = json_response(conn, 200)
|
assert %{"plot" => plot} = json_response(conn, 200)
|
||||||
assert %{"name" => "Time on page", "value" => 900, "change" => 100} in res["top_stats"]
|
|
||||||
|
assert Enum.count(plot) == 31
|
||||||
|
assert List.first(plot) == 2
|
||||||
|
assert List.last(plot) == 1
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "GET /api/stats/main-graph - realtime top stats" do
|
describe "GET /api/stats/main-graph - bounce_rate plot" do
|
||||||
setup [:create_user, :log_in, :create_new_site]
|
setup [:create_user, :log_in, :create_new_site]
|
||||||
|
|
||||||
test "shows current visitors (last 5 minutes)", %{conn: conn, site: site} do
|
test "displays bounce_rate for a month", %{conn: conn, site: site} do
|
||||||
populate_stats(site, [
|
populate_stats(site, [
|
||||||
build(:pageview, timestamp: relative_time(minutes: -10)),
|
build(:pageview, timestamp: ~N[2021-01-03 00:00:00]),
|
||||||
build(:pageview, timestamp: relative_time(minutes: -4)),
|
build(:pageview, timestamp: ~N[2021-01-03 00:10:00]),
|
||||||
build(:pageview, timestamp: relative_time(minutes: -1))
|
build(:pageview, timestamp: ~N[2021-01-31 00:00:00])
|
||||||
])
|
])
|
||||||
|
|
||||||
conn = get(conn, "/api/stats/#{site.domain}/main-graph?period=realtime")
|
conn =
|
||||||
|
get(
|
||||||
|
conn,
|
||||||
|
"/api/stats/#{site.domain}/main-graph?period=month&date=2021-01-01&metric=bounce_rate"
|
||||||
|
)
|
||||||
|
|
||||||
res = json_response(conn, 200)
|
assert %{"plot" => plot} = json_response(conn, 200)
|
||||||
assert %{"name" => "Current visitors", "value" => 2} in res["top_stats"]
|
|
||||||
end
|
|
||||||
|
|
||||||
test "shows unique visitors (last 30 minutes)", %{conn: conn, site: site} do
|
assert Enum.count(plot) == 31
|
||||||
populate_stats(site, [
|
assert List.first(plot) == 0
|
||||||
build(:pageview, timestamp: relative_time(minutes: -45)),
|
assert List.last(plot) == 100
|
||||||
build(:pageview, timestamp: relative_time(minutes: -25)),
|
|
||||||
build(:pageview, timestamp: relative_time(minutes: -1))
|
|
||||||
])
|
|
||||||
|
|
||||||
conn = get(conn, "/api/stats/#{site.domain}/main-graph?period=realtime")
|
|
||||||
|
|
||||||
res = json_response(conn, 200)
|
|
||||||
assert %{"name" => "Unique visitors (last 30 min)", "value" => 2} in res["top_stats"]
|
|
||||||
end
|
|
||||||
|
|
||||||
test "shows pageviews (last 30 minutes)", %{conn: conn, site: site} do
|
|
||||||
populate_stats(site, [
|
|
||||||
build(:pageview, user_id: @user_id, timestamp: relative_time(minutes: -45)),
|
|
||||||
build(:pageview, user_id: @user_id, timestamp: relative_time(minutes: -25)),
|
|
||||||
build(:pageview, user_id: @user_id, timestamp: relative_time(minutes: -20)),
|
|
||||||
build(:pageview, timestamp: relative_time(minutes: -1))
|
|
||||||
])
|
|
||||||
|
|
||||||
conn = get(conn, "/api/stats/#{site.domain}/main-graph?period=realtime")
|
|
||||||
|
|
||||||
res = json_response(conn, 200)
|
|
||||||
assert %{"name" => "Pageviews (last 30 min)", "value" => 3} in res["top_stats"]
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "GET /api/stats/main-graph - filtered for goal" do
|
describe "GET /api/stats/main-graph - visit_duration plot" do
|
||||||
setup [:create_user, :log_in, :create_new_site]
|
setup [:create_user, :log_in, :create_new_site]
|
||||||
|
|
||||||
test "returns total unique visitors", %{conn: conn, site: site} do
|
test "displays visit_duration for a month", %{conn: conn, site: site} do
|
||||||
populate_stats(site, [
|
populate_stats(site, [
|
||||||
build(:pageview, user_id: @user_id),
|
build(:pageview,
|
||||||
build(:pageview, user_id: @user_id),
|
pathname: "/page3",
|
||||||
build(:pageview),
|
user_id: @user_id,
|
||||||
build(:event, name: "Signup")
|
timestamp: ~N[2021-01-31 00:10:00]
|
||||||
|
),
|
||||||
|
build(:pageview,
|
||||||
|
pathname: "/page2",
|
||||||
|
user_id: @user_id,
|
||||||
|
timestamp: ~N[2021-01-31 00:15:00]
|
||||||
|
)
|
||||||
])
|
])
|
||||||
|
|
||||||
filters = Jason.encode!(%{goal: "Signup"})
|
|
||||||
|
|
||||||
conn =
|
conn =
|
||||||
get(
|
get(
|
||||||
conn,
|
conn,
|
||||||
"/api/stats/#{site.domain}/main-graph?period=day&filters=#{filters}"
|
"/api/stats/#{site.domain}/main-graph?period=month&date=2021-01-01&metric=visit_duration"
|
||||||
)
|
)
|
||||||
|
|
||||||
res = json_response(conn, 200)
|
assert %{"plot" => plot} = json_response(conn, 200)
|
||||||
assert %{"name" => "Unique visitors", "value" => 3, "change" => 100} in res["top_stats"]
|
|
||||||
end
|
|
||||||
|
|
||||||
test "returns converted visitors", %{conn: conn, site: site} do
|
assert Enum.count(plot) == 31
|
||||||
populate_stats(site, [
|
assert List.first(plot) == 0
|
||||||
build(:pageview, user_id: @user_id),
|
assert List.last(plot) == 300
|
||||||
build(:pageview, user_id: @user_id),
|
|
||||||
build(:pageview),
|
|
||||||
build(:event, name: "Signup")
|
|
||||||
])
|
|
||||||
|
|
||||||
filters = Jason.encode!(%{goal: "Signup"})
|
|
||||||
|
|
||||||
conn =
|
|
||||||
get(
|
|
||||||
conn,
|
|
||||||
"/api/stats/#{site.domain}/main-graph?period=month&filters=#{filters}"
|
|
||||||
)
|
|
||||||
|
|
||||||
res = json_response(conn, 200)
|
|
||||||
assert %{"name" => "Unique conversions", "value" => 1, "change" => 100} in res["top_stats"]
|
|
||||||
end
|
|
||||||
|
|
||||||
test "returns conversion rate", %{conn: conn, site: site} do
|
|
||||||
populate_stats(site, [
|
|
||||||
build(:pageview, user_id: @user_id),
|
|
||||||
build(:pageview, user_id: @user_id),
|
|
||||||
build(:pageview),
|
|
||||||
build(:event, name: "Signup")
|
|
||||||
])
|
|
||||||
|
|
||||||
filters = Jason.encode!(%{goal: "Signup"})
|
|
||||||
|
|
||||||
conn =
|
|
||||||
get(
|
|
||||||
conn,
|
|
||||||
"/api/stats/#{site.domain}/main-graph?period=day&filters=#{filters}"
|
|
||||||
)
|
|
||||||
|
|
||||||
res = json_response(conn, 200)
|
|
||||||
|
|
||||||
assert %{"name" => "Conversion rate", "value" => 33.3, "change" => 100} in res["top_stats"]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe "GET /api/stats/main-graph - top stats - filters" do
|
|
||||||
setup [:create_user, :log_in, :create_new_site]
|
|
||||||
|
|
||||||
test "returns only visitors from a country based on alpha2 code", %{conn: conn, site: site} do
|
|
||||||
populate_stats(site, [
|
|
||||||
build(:pageview, country_code: "US"),
|
|
||||||
build(:pageview, country_code: "US"),
|
|
||||||
build(:pageview, country_code: "EE")
|
|
||||||
])
|
|
||||||
|
|
||||||
filters = Jason.encode!(%{country: "US"})
|
|
||||||
|
|
||||||
conn =
|
|
||||||
get(
|
|
||||||
conn,
|
|
||||||
"/api/stats/#{site.domain}/main-graph?period=month&filters=#{filters}"
|
|
||||||
)
|
|
||||||
|
|
||||||
res = json_response(conn, 200)
|
|
||||||
assert %{"name" => "Unique visitors", "value" => 2, "change" => 100} in res["top_stats"]
|
|
||||||
end
|
|
||||||
|
|
||||||
test "page glob filter", %{conn: conn, site: site} do
|
|
||||||
populate_stats(site, [
|
|
||||||
build(:pageview, pathname: "/index"),
|
|
||||||
build(:pageview, pathname: "/blog/post1"),
|
|
||||||
build(:pageview, pathname: "/blog/post2")
|
|
||||||
])
|
|
||||||
|
|
||||||
filters = Jason.encode!(%{page: "/blog/**"})
|
|
||||||
|
|
||||||
conn =
|
|
||||||
get(
|
|
||||||
conn,
|
|
||||||
"/api/stats/#{site.domain}/main-graph?period=month&filters=#{filters}"
|
|
||||||
)
|
|
||||||
|
|
||||||
res = json_response(conn, 200)
|
|
||||||
assert %{"name" => "Unique visitors", "value" => 2, "change" => 100} in res["top_stats"]
|
|
||||||
end
|
|
||||||
|
|
||||||
test "contains (~) filter", %{conn: conn, site: site} do
|
|
||||||
populate_stats(site, [
|
|
||||||
build(:pageview, pathname: "/some-blog-post"),
|
|
||||||
build(:pageview, pathname: "/blog/post1"),
|
|
||||||
build(:pageview, pathname: "/another/post")
|
|
||||||
])
|
|
||||||
|
|
||||||
filters = Jason.encode!(%{page: "~blog"})
|
|
||||||
|
|
||||||
conn =
|
|
||||||
get(
|
|
||||||
conn,
|
|
||||||
"/api/stats/#{site.domain}/main-graph?period=month&filters=#{filters}"
|
|
||||||
)
|
|
||||||
|
|
||||||
res = json_response(conn, 200)
|
|
||||||
assert %{"name" => "Unique visitors", "value" => 2, "change" => 100} in res["top_stats"]
|
|
||||||
end
|
|
||||||
|
|
||||||
test "returns only visitors with specific screen size", %{conn: conn, site: site} do
|
|
||||||
populate_stats(site, [
|
|
||||||
build(:pageview, screen_size: "Desktop"),
|
|
||||||
build(:pageview, screen_size: "Desktop"),
|
|
||||||
build(:pageview, screen_size: "Mobile")
|
|
||||||
])
|
|
||||||
|
|
||||||
filters = Jason.encode!(%{screen: "Desktop"})
|
|
||||||
|
|
||||||
conn =
|
|
||||||
get(
|
|
||||||
conn,
|
|
||||||
"/api/stats/#{site.domain}/main-graph?period=month&filters=#{filters}"
|
|
||||||
)
|
|
||||||
|
|
||||||
res = json_response(conn, 200)
|
|
||||||
assert %{"name" => "Unique visitors", "value" => 2, "change" => 100} in res["top_stats"]
|
|
||||||
end
|
|
||||||
|
|
||||||
test "returns only visitors with specific browser", %{conn: conn, site: site} do
|
|
||||||
populate_stats(site, [
|
|
||||||
build(:pageview, browser: "Chrome"),
|
|
||||||
build(:pageview, browser: "Chrome"),
|
|
||||||
build(:pageview, browser: "Safari")
|
|
||||||
])
|
|
||||||
|
|
||||||
filters = Jason.encode!(%{browser: "Chrome"})
|
|
||||||
|
|
||||||
conn =
|
|
||||||
get(
|
|
||||||
conn,
|
|
||||||
"/api/stats/#{site.domain}/main-graph?period=month&filters=#{filters}"
|
|
||||||
)
|
|
||||||
|
|
||||||
res = json_response(conn, 200)
|
|
||||||
assert %{"name" => "Unique visitors", "value" => 2, "change" => 100} in res["top_stats"]
|
|
||||||
end
|
|
||||||
|
|
||||||
test "returns only visitors with specific operating system", %{conn: conn, site: site} do
|
|
||||||
populate_stats(site, [
|
|
||||||
build(:pageview, operating_system: "Mac"),
|
|
||||||
build(:pageview, operating_system: "Mac"),
|
|
||||||
build(:pageview, operating_system: "Windows")
|
|
||||||
])
|
|
||||||
|
|
||||||
filters = Jason.encode!(%{os: "Mac"})
|
|
||||||
|
|
||||||
conn =
|
|
||||||
get(
|
|
||||||
conn,
|
|
||||||
"/api/stats/#{site.domain}/main-graph?period=month&filters=#{filters}"
|
|
||||||
)
|
|
||||||
|
|
||||||
res = json_response(conn, 200)
|
|
||||||
assert %{"name" => "Unique visitors", "value" => 2, "change" => 100} in res["top_stats"]
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -0,0 +1,326 @@
|
|||||||
|
defmodule PlausibleWeb.Api.StatsController.TopStatsTest do
|
||||||
|
use PlausibleWeb.ConnCase
|
||||||
|
import Plausible.TestUtils
|
||||||
|
@user_id 123
|
||||||
|
|
||||||
|
describe "GET /api/stats/top-stats - default" do
|
||||||
|
setup [:create_user, :log_in, :create_new_site]
|
||||||
|
|
||||||
|
test "counts distinct user ids", %{conn: conn, site: site} do
|
||||||
|
populate_stats(site, [
|
||||||
|
build(:pageview, user_id: @user_id),
|
||||||
|
build(:pageview, user_id: @user_id),
|
||||||
|
build(:pageview)
|
||||||
|
])
|
||||||
|
|
||||||
|
conn = get(conn, "/api/stats/#{site.domain}/top-stats?period=day")
|
||||||
|
|
||||||
|
res = json_response(conn, 200)
|
||||||
|
assert %{"name" => "Unique visitors", "value" => 2, "change" => 100} in res["top_stats"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "counts total pageviews", %{conn: conn, site: site} do
|
||||||
|
populate_stats(site, [
|
||||||
|
build(:pageview, user_id: @user_id),
|
||||||
|
build(:pageview, user_id: @user_id),
|
||||||
|
build(:pageview)
|
||||||
|
])
|
||||||
|
|
||||||
|
conn = get(conn, "/api/stats/#{site.domain}/top-stats?period=day")
|
||||||
|
|
||||||
|
res = json_response(conn, 200)
|
||||||
|
assert %{"name" => "Total pageviews", "value" => 3, "change" => 100} in res["top_stats"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "calculates bounce rate", %{conn: conn, site: site} do
|
||||||
|
populate_stats(site, [
|
||||||
|
build(:pageview, user_id: @user_id),
|
||||||
|
build(:pageview, user_id: @user_id),
|
||||||
|
build(:pageview)
|
||||||
|
])
|
||||||
|
|
||||||
|
conn = get(conn, "/api/stats/#{site.domain}/top-stats?period=day")
|
||||||
|
|
||||||
|
res = json_response(conn, 200)
|
||||||
|
assert %{"name" => "Bounce rate", "value" => 50, "change" => nil} in res["top_stats"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "calculates average visit duration", %{conn: conn, site: site} do
|
||||||
|
populate_stats(site, [
|
||||||
|
build(:pageview,
|
||||||
|
user_id: @user_id,
|
||||||
|
timestamp: ~N[2021-01-01 00:00:00]
|
||||||
|
),
|
||||||
|
build(:pageview,
|
||||||
|
user_id: @user_id,
|
||||||
|
timestamp: ~N[2021-01-01 00:15:00]
|
||||||
|
),
|
||||||
|
build(:pageview,
|
||||||
|
timestamp: ~N[2021-01-01 00:15:00]
|
||||||
|
)
|
||||||
|
])
|
||||||
|
|
||||||
|
conn = get(conn, "/api/stats/#{site.domain}/top-stats?period=day&date=2021-01-01")
|
||||||
|
|
||||||
|
res = json_response(conn, 200)
|
||||||
|
assert %{"name" => "Visit duration", "value" => 450, "change" => 100} in res["top_stats"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "calculates time on page instead when filtered for page", %{conn: conn, site: site} do
|
||||||
|
populate_stats(site, [
|
||||||
|
build(:pageview,
|
||||||
|
pathname: "/pageA",
|
||||||
|
user_id: @user_id,
|
||||||
|
timestamp: ~N[2021-01-01 00:00:00]
|
||||||
|
),
|
||||||
|
build(:pageview,
|
||||||
|
pathname: "/pageB",
|
||||||
|
user_id: @user_id,
|
||||||
|
timestamp: ~N[2021-01-01 00:15:00]
|
||||||
|
),
|
||||||
|
build(:pageview,
|
||||||
|
pathname: "/pageA",
|
||||||
|
timestamp: ~N[2021-01-01 00:15:00]
|
||||||
|
)
|
||||||
|
])
|
||||||
|
|
||||||
|
filters = Jason.encode!(%{page: "/pageA"})
|
||||||
|
|
||||||
|
conn =
|
||||||
|
get(
|
||||||
|
conn,
|
||||||
|
"/api/stats/#{site.domain}/top-stats?period=day&date=2021-01-01&filters=#{filters}"
|
||||||
|
)
|
||||||
|
|
||||||
|
res = json_response(conn, 200)
|
||||||
|
assert %{"name" => "Time on page", "value" => 900, "change" => 100} in res["top_stats"]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "GET /api/stats/top-stats - realtime" do
|
||||||
|
setup [:create_user, :log_in, :create_new_site]
|
||||||
|
|
||||||
|
test "shows current visitors (last 5 minutes)", %{conn: conn, site: site} do
|
||||||
|
populate_stats(site, [
|
||||||
|
build(:pageview, timestamp: relative_time(minutes: -10)),
|
||||||
|
build(:pageview, timestamp: relative_time(minutes: -4)),
|
||||||
|
build(:pageview, timestamp: relative_time(minutes: -1))
|
||||||
|
])
|
||||||
|
|
||||||
|
conn = get(conn, "/api/stats/#{site.domain}/top-stats?period=realtime")
|
||||||
|
|
||||||
|
res = json_response(conn, 200)
|
||||||
|
assert %{"name" => "Current visitors", "value" => 2} in res["top_stats"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "shows unique visitors (last 30 minutes)", %{conn: conn, site: site} do
|
||||||
|
populate_stats(site, [
|
||||||
|
build(:pageview, timestamp: relative_time(minutes: -45)),
|
||||||
|
build(:pageview, timestamp: relative_time(minutes: -25)),
|
||||||
|
build(:pageview, timestamp: relative_time(minutes: -1))
|
||||||
|
])
|
||||||
|
|
||||||
|
conn = get(conn, "/api/stats/#{site.domain}/top-stats?period=realtime")
|
||||||
|
|
||||||
|
res = json_response(conn, 200)
|
||||||
|
assert %{"name" => "Unique visitors (last 30 min)", "value" => 2} in res["top_stats"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "shows pageviews (last 30 minutes)", %{conn: conn, site: site} do
|
||||||
|
populate_stats(site, [
|
||||||
|
build(:pageview, user_id: @user_id, timestamp: relative_time(minutes: -45)),
|
||||||
|
build(:pageview, user_id: @user_id, timestamp: relative_time(minutes: -25)),
|
||||||
|
build(:pageview, user_id: @user_id, timestamp: relative_time(minutes: -20)),
|
||||||
|
build(:pageview, timestamp: relative_time(minutes: -1))
|
||||||
|
])
|
||||||
|
|
||||||
|
conn = get(conn, "/api/stats/#{site.domain}/top-stats?period=realtime")
|
||||||
|
|
||||||
|
res = json_response(conn, 200)
|
||||||
|
assert %{"name" => "Pageviews (last 30 min)", "value" => 3} in res["top_stats"]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "GET /api/stats/top-stats - filters" do
|
||||||
|
setup [:create_user, :log_in, :create_new_site]
|
||||||
|
|
||||||
|
test "returns only visitors from a country based on alpha2 code", %{conn: conn, site: site} do
|
||||||
|
populate_stats(site, [
|
||||||
|
build(:pageview, country_code: "US"),
|
||||||
|
build(:pageview, country_code: "US"),
|
||||||
|
build(:pageview, country_code: "EE")
|
||||||
|
])
|
||||||
|
|
||||||
|
filters = Jason.encode!(%{country: "US"})
|
||||||
|
|
||||||
|
conn =
|
||||||
|
get(
|
||||||
|
conn,
|
||||||
|
"/api/stats/#{site.domain}/top-stats?period=month&filters=#{filters}"
|
||||||
|
)
|
||||||
|
|
||||||
|
res = json_response(conn, 200)
|
||||||
|
assert %{"name" => "Unique visitors", "value" => 2, "change" => 100} in res["top_stats"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "page glob filter", %{conn: conn, site: site} do
|
||||||
|
populate_stats(site, [
|
||||||
|
build(:pageview, pathname: "/index"),
|
||||||
|
build(:pageview, pathname: "/blog/post1"),
|
||||||
|
build(:pageview, pathname: "/blog/post2")
|
||||||
|
])
|
||||||
|
|
||||||
|
filters = Jason.encode!(%{page: "/blog/**"})
|
||||||
|
|
||||||
|
conn =
|
||||||
|
get(
|
||||||
|
conn,
|
||||||
|
"/api/stats/#{site.domain}/top-stats?period=month&filters=#{filters}"
|
||||||
|
)
|
||||||
|
|
||||||
|
res = json_response(conn, 200)
|
||||||
|
assert %{"name" => "Unique visitors", "value" => 2, "change" => 100} in res["top_stats"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "contains (~) filter", %{conn: conn, site: site} do
|
||||||
|
populate_stats(site, [
|
||||||
|
build(:pageview, pathname: "/some-blog-post"),
|
||||||
|
build(:pageview, pathname: "/blog/post1"),
|
||||||
|
build(:pageview, pathname: "/another/post")
|
||||||
|
])
|
||||||
|
|
||||||
|
filters = Jason.encode!(%{page: "~blog"})
|
||||||
|
|
||||||
|
conn =
|
||||||
|
get(
|
||||||
|
conn,
|
||||||
|
"/api/stats/#{site.domain}/top-stats?period=month&filters=#{filters}"
|
||||||
|
)
|
||||||
|
|
||||||
|
res = json_response(conn, 200)
|
||||||
|
assert %{"name" => "Unique visitors", "value" => 2, "change" => 100} in res["top_stats"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns only visitors with specific screen size", %{conn: conn, site: site} do
|
||||||
|
populate_stats(site, [
|
||||||
|
build(:pageview, screen_size: "Desktop"),
|
||||||
|
build(:pageview, screen_size: "Desktop"),
|
||||||
|
build(:pageview, screen_size: "Mobile")
|
||||||
|
])
|
||||||
|
|
||||||
|
filters = Jason.encode!(%{screen: "Desktop"})
|
||||||
|
|
||||||
|
conn =
|
||||||
|
get(
|
||||||
|
conn,
|
||||||
|
"/api/stats/#{site.domain}/top-stats?period=month&filters=#{filters}"
|
||||||
|
)
|
||||||
|
|
||||||
|
res = json_response(conn, 200)
|
||||||
|
assert %{"name" => "Unique visitors", "value" => 2, "change" => 100} in res["top_stats"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns only visitors with specific browser", %{conn: conn, site: site} do
|
||||||
|
populate_stats(site, [
|
||||||
|
build(:pageview, browser: "Chrome"),
|
||||||
|
build(:pageview, browser: "Chrome"),
|
||||||
|
build(:pageview, browser: "Safari")
|
||||||
|
])
|
||||||
|
|
||||||
|
filters = Jason.encode!(%{browser: "Chrome"})
|
||||||
|
|
||||||
|
conn =
|
||||||
|
get(
|
||||||
|
conn,
|
||||||
|
"/api/stats/#{site.domain}/top-stats?period=month&filters=#{filters}"
|
||||||
|
)
|
||||||
|
|
||||||
|
res = json_response(conn, 200)
|
||||||
|
assert %{"name" => "Unique visitors", "value" => 2, "change" => 100} in res["top_stats"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns only visitors with specific operating system", %{conn: conn, site: site} do
|
||||||
|
populate_stats(site, [
|
||||||
|
build(:pageview, operating_system: "Mac"),
|
||||||
|
build(:pageview, operating_system: "Mac"),
|
||||||
|
build(:pageview, operating_system: "Windows")
|
||||||
|
])
|
||||||
|
|
||||||
|
filters = Jason.encode!(%{os: "Mac"})
|
||||||
|
|
||||||
|
conn =
|
||||||
|
get(
|
||||||
|
conn,
|
||||||
|
"/api/stats/#{site.domain}/top-stats?period=month&filters=#{filters}"
|
||||||
|
)
|
||||||
|
|
||||||
|
res = json_response(conn, 200)
|
||||||
|
assert %{"name" => "Unique visitors", "value" => 2, "change" => 100} in res["top_stats"]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "GET /api/stats/top-stats - filtered for goal" do
|
||||||
|
setup [:create_user, :log_in, :create_new_site]
|
||||||
|
|
||||||
|
test "returns total unique visitors", %{conn: conn, site: site} do
|
||||||
|
populate_stats(site, [
|
||||||
|
build(:pageview, user_id: @user_id),
|
||||||
|
build(:pageview, user_id: @user_id),
|
||||||
|
build(:pageview),
|
||||||
|
build(:event, name: "Signup")
|
||||||
|
])
|
||||||
|
|
||||||
|
filters = Jason.encode!(%{goal: "Signup"})
|
||||||
|
|
||||||
|
conn =
|
||||||
|
get(
|
||||||
|
conn,
|
||||||
|
"/api/stats/#{site.domain}/top-stats?period=day&filters=#{filters}"
|
||||||
|
)
|
||||||
|
|
||||||
|
res = json_response(conn, 200)
|
||||||
|
assert %{"name" => "Unique visitors", "value" => 3, "change" => 100} in res["top_stats"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns converted visitors", %{conn: conn, site: site} do
|
||||||
|
populate_stats(site, [
|
||||||
|
build(:pageview, user_id: @user_id),
|
||||||
|
build(:pageview, user_id: @user_id),
|
||||||
|
build(:pageview),
|
||||||
|
build(:event, name: "Signup")
|
||||||
|
])
|
||||||
|
|
||||||
|
filters = Jason.encode!(%{goal: "Signup"})
|
||||||
|
|
||||||
|
conn =
|
||||||
|
get(
|
||||||
|
conn,
|
||||||
|
"/api/stats/#{site.domain}/top-stats?period=month&filters=#{filters}"
|
||||||
|
)
|
||||||
|
|
||||||
|
res = json_response(conn, 200)
|
||||||
|
assert %{"name" => "Unique conversions", "value" => 1, "change" => 100} in res["top_stats"]
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns conversion rate", %{conn: conn, site: site} do
|
||||||
|
populate_stats(site, [
|
||||||
|
build(:pageview, user_id: @user_id),
|
||||||
|
build(:pageview, user_id: @user_id),
|
||||||
|
build(:pageview),
|
||||||
|
build(:event, name: "Signup")
|
||||||
|
])
|
||||||
|
|
||||||
|
filters = Jason.encode!(%{goal: "Signup"})
|
||||||
|
|
||||||
|
conn =
|
||||||
|
get(
|
||||||
|
conn,
|
||||||
|
"/api/stats/#{site.domain}/top-stats?period=day&filters=#{filters}"
|
||||||
|
)
|
||||||
|
|
||||||
|
res = json_response(conn, 200)
|
||||||
|
|
||||||
|
assert %{"name" => "Conversion rate", "value" => 33.3, "change" => 100} in res["top_stats"]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
Loading…
Reference in New Issue
Block a user