mirror of
https://github.com/plausible/analytics.git
synced 2024-12-23 17:44:43 +03:00
Refactor VisitorGraph (#3936)
* Give a more semantic name to a function * Make the LineGraph component thinner * Move LineGraph into a separate file * Move interval logic into interval-picker.js This commit also fixes a bug where the interval name displayed inside the picker component flickers the default interval when the graph is loading. The problem was that we were counting on graphData for returning us the current interval: `let currentInterval = graphData?.interval` We should always know the default interval before making the main-graph request. Sending graphData to IntervalPicker component does not make sense anyway. * extract data fetching functions out of VisitorGraph component * Return graph_metric key from Top Stats API This commit introduces no behavioral changes - only starts returning an additional field, allowing us to avoid the following logic in React: 1. Finding the metric names, given a stat display name. E.g. `Unique visitors (last 30 min) -> visitors` 2. Checking if a metric is graphable or not * Move metric state into localStorage This commit gets rid of the internal `metric` state in the VisitorGraph component and starts using localStorage for that instead. This commit also chains the main-graph request into the top-stats request callback - meaning that we'll always fetch new graph data after top stats are updated. And we do it all in a single function. Doing so simplifies the loading state significantly, and also helps to make it clear, that at all times, existing top stats are required before we can fetch the graph. That's because the metric is determined by which Top stats are returned (for example, we can't be sure whether revenue metrics will be returned or not). * Make sure graph tooltip says "Converted Visitors" * Extract a StatsExport function component Again, instead of relying on `graphData?.interval` we can read it from localStorage, or default to the largest interval available. The export should not be dependant on the graph. * Extract SamplingNotice function component * Extract WithImportedSwitch function component * Stop "lazy-loading" the graph and top stats Since the container is always on top on the page, it will be visible on the first render in any case - no matter the screen size. * Turn VisitorGraph into a function component * Display empty container until everything has loaded * Do not display loading spinner on realtime ticks * Turn Top Stats into a fn component * fetch top stats and graph async * Make sure revenue metrics can remain on the graph * Add an extra check to canMetricBeGraphed * fix typo * remove redundant double negation
This commit is contained in:
parent
3115c6e7a8
commit
e5b56dbe62
@ -22,6 +22,7 @@ if (container) {
|
|||||||
conversionsOptedOut: container.dataset.conversionsOptedOut === 'true',
|
conversionsOptedOut: container.dataset.conversionsOptedOut === 'true',
|
||||||
funnelsOptedOut: container.dataset.funnelsOptedOut === 'true',
|
funnelsOptedOut: container.dataset.funnelsOptedOut === 'true',
|
||||||
propsOptedOut: container.dataset.propsOptedOut === 'true',
|
propsOptedOut: container.dataset.propsOptedOut === 'true',
|
||||||
|
revenueGoals: JSON.parse(container.dataset.revenueGoals),
|
||||||
funnels: JSON.parse(container.dataset.funnels),
|
funnels: JSON.parse(container.dataset.funnels),
|
||||||
statsBegin: container.dataset.statsBegin,
|
statsBegin: container.dataset.statsBegin,
|
||||||
nativeStatsBegin: container.dataset.nativeStatsBegin,
|
nativeStatsBegin: container.dataset.nativeStatsBegin,
|
||||||
|
@ -1,19 +1,41 @@
|
|||||||
import numberFormatter, {durationFormatter} from '../../util/number-formatter'
|
import numberFormatter, {durationFormatter} from '../../util/number-formatter'
|
||||||
|
import { parsePrefix } from '../../util/filters'
|
||||||
|
|
||||||
export const METRIC_MAPPING = {
|
export function getGraphableMetrics(query, site) {
|
||||||
'Unique visitors (last 30 min)': 'visitors',
|
const isRealtime = query.period === 'realtime'
|
||||||
'Pageviews (last 30 min)': 'pageviews',
|
const goalFilter = query.filters.goal
|
||||||
'Unique visitors': 'visitors',
|
const pageFilter = query.filters.page
|
||||||
'Visit duration': 'visit_duration',
|
|
||||||
'Total pageviews': 'pageviews',
|
if (isRealtime && goalFilter) {
|
||||||
'Views per visit': 'views_per_visit',
|
return ["visitors"]
|
||||||
'Total visits': 'visits',
|
} else if (isRealtime) {
|
||||||
'Bounce rate': 'bounce_rate',
|
return ["visitors", "pageviews"]
|
||||||
'Unique conversions': 'conversions',
|
} else if (goalFilter && canGraphRevenueMetrics(goalFilter, site)) {
|
||||||
'Total conversions': 'events',
|
return ["visitors", "events", "average_revenue", "total_revenue", "conversion_rate"]
|
||||||
'Conversion rate': 'conversion_rate',
|
} else if (goalFilter) {
|
||||||
'Average revenue': 'average_revenue',
|
return ["visitors", "events", "conversion_rate"]
|
||||||
'Total revenue': 'total_revenue',
|
} else if (pageFilter) {
|
||||||
|
return ["visitors", "visits", "pageviews", "bounce_rate", "time_on_page"]
|
||||||
|
} else {
|
||||||
|
return ["visitors", "visits", "pageviews", "views_per_visit", "bounce_rate", "visit_duration"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revenue metrics can only be graphed if:
|
||||||
|
// * The query is filtered by at least one revenue goal
|
||||||
|
// * All revenue goals in filter have the same currency
|
||||||
|
function canGraphRevenueMetrics(goalFilter, site) {
|
||||||
|
const goalsInFilter = parsePrefix(goalFilter).values
|
||||||
|
|
||||||
|
const revenueGoalsInFilter = site.revenueGoals.filter((rg) => {
|
||||||
|
return goalsInFilter.includes(rg.event_name)
|
||||||
|
})
|
||||||
|
|
||||||
|
const singleCurrency = revenueGoalsInFilter.every((rg) => {
|
||||||
|
return rg.currency === revenueGoalsInFilter[0].currency
|
||||||
|
})
|
||||||
|
|
||||||
|
return revenueGoalsInFilter.length > 0 && singleCurrency
|
||||||
}
|
}
|
||||||
|
|
||||||
export const METRIC_LABELS = {
|
export const METRIC_LABELS = {
|
||||||
@ -44,14 +66,6 @@ export const METRIC_FORMATTER = {
|
|||||||
'average_revenue': numberFormatter,
|
'average_revenue': numberFormatter,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LoadingState = {
|
|
||||||
loading: 'loading',
|
|
||||||
refreshing: 'refreshing',
|
|
||||||
loaded: 'loaded',
|
|
||||||
isLoadingOrRefreshing: function (state) { return [this.loading, this.refreshing].includes(state) },
|
|
||||||
isLoadedOrRefreshing: function (state) { return [this.loaded, this.refreshing].includes(state) }
|
|
||||||
}
|
|
||||||
|
|
||||||
const buildComparisonDataset = function(comparisonPlot) {
|
const buildComparisonDataset = function(comparisonPlot) {
|
||||||
if (!comparisonPlot) return []
|
if (!comparisonPlot) return []
|
||||||
|
|
||||||
|
@ -6,7 +6,7 @@ import * as storage from '../../util/storage'
|
|||||||
import { isKeyPressed } from '../../keybinding.js'
|
import { isKeyPressed } from '../../keybinding.js'
|
||||||
import { monthsBetweenDates } from '../../util/date.js'
|
import { monthsBetweenDates } from '../../util/date.js'
|
||||||
|
|
||||||
export const INTERVAL_LABELS = {
|
const INTERVAL_LABELS = {
|
||||||
'minute': 'Minutes',
|
'minute': 'Minutes',
|
||||||
'hour': 'Hours',
|
'hour': 'Hours',
|
||||||
'date': 'Days',
|
'date': 'Days',
|
||||||
@ -14,11 +14,19 @@ export const INTERVAL_LABELS = {
|
|||||||
'month': 'Months'
|
'month': 'Months'
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getStoredInterval = function(period, domain) {
|
function validIntervals(site, query) {
|
||||||
|
if (query.period === "custom" && monthsBetweenDates(query.from, query.to) > 12) {
|
||||||
|
return ["week", "month"]
|
||||||
|
} else {
|
||||||
|
return site.validIntervalsByPeriod[query.period]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStoredInterval(period, domain) {
|
||||||
return storage.getItem(`interval__${period}__${domain}`)
|
return storage.getItem(`interval__${period}__${domain}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const storeInterval = function(period, domain, interval) {
|
function storeInterval(period, domain, interval) {
|
||||||
storage.setItem(`interval__${period}__${domain}`, interval)
|
storage.setItem(`interval__${period}__${domain}`, interval)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -33,37 +41,47 @@ function subscribeKeybinding(element) {
|
|||||||
}, [handleKeyPress])
|
}, [handleKeyPress])
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownItem({ option, currentInterval, updateInterval }) {
|
export const getCurrentInterval = function(site, query) {
|
||||||
return (
|
const options = validIntervals(site, query)
|
||||||
<Menu.Item onClick={() => updateInterval(option)} key={option} disabled={option == currentInterval}>
|
|
||||||
{({ active }) => (
|
const storedInterval = getStoredInterval(query.period, site.domain)
|
||||||
<span className={classNames({
|
const defaultInterval = [...options].pop()
|
||||||
'bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-200 cursor-pointer': active,
|
|
||||||
'text-gray-700 dark:text-gray-200': !active,
|
if (storedInterval && options.includes(storedInterval)) {
|
||||||
'font-bold cursor-none select-none': option == currentInterval,
|
return storedInterval
|
||||||
}, 'block px-4 py-2 text-sm')}>
|
} else {
|
||||||
{INTERVAL_LABELS[option]}
|
return defaultInterval
|
||||||
</span>
|
}
|
||||||
)}
|
|
||||||
</Menu.Item>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function IntervalPicker({ graphData, query, site, updateInterval }) {
|
export function IntervalPicker({ query, site, onIntervalUpdate }) {
|
||||||
if (query.period == 'realtime') return null
|
if (query.period == 'realtime') return null
|
||||||
|
|
||||||
const menuElement = React.useRef(null)
|
const menuElement = React.useRef(null)
|
||||||
|
const options = validIntervals(site, query)
|
||||||
|
const currentInterval = getCurrentInterval(site, query)
|
||||||
|
|
||||||
subscribeKeybinding(menuElement)
|
subscribeKeybinding(menuElement)
|
||||||
|
|
||||||
let currentInterval = graphData?.interval
|
function updateInterval(interval) {
|
||||||
|
storeInterval(query.period, site.domain, interval)
|
||||||
let options = site.validIntervalsByPeriod[query.period]
|
onIntervalUpdate(interval)
|
||||||
if (query.period === "custom" && monthsBetweenDates(query.from, query.to) > 12) {
|
|
||||||
options = ["week", "month"]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!options.includes(currentInterval)) {
|
function renderDropdownItem(option) {
|
||||||
currentInterval = [...options].pop()
|
return (
|
||||||
|
<Menu.Item onClick={() => updateInterval(option)} key={option} disabled={option == currentInterval}>
|
||||||
|
{({ active }) => (
|
||||||
|
<span className={classNames({
|
||||||
|
'bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-200 cursor-pointer': active,
|
||||||
|
'text-gray-700 dark:text-gray-200': !active,
|
||||||
|
'font-bold cursor-none select-none': option == currentInterval,
|
||||||
|
}, 'block px-4 py-2 text-sm')}>
|
||||||
|
{INTERVAL_LABELS[option]}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Menu.Item>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -85,7 +103,7 @@ export function IntervalPicker({ graphData, query, site, updateInterval }) {
|
|||||||
leaveFrom="opacity-100 scale-100"
|
leaveFrom="opacity-100 scale-100"
|
||||||
leaveTo="opacity-0 scale-95">
|
leaveTo="opacity-0 scale-95">
|
||||||
<Menu.Items className="py-1 text-left origin-top-right absolute right-0 mt-2 w-56 rounded-md shadow-lg bg-white dark:bg-gray-800 ring-1 ring-black ring-opacity-5 focus:outline-none z-10" static>
|
<Menu.Items className="py-1 text-left origin-top-right absolute right-0 mt-2 w-56 rounded-md shadow-lg bg-white dark:bg-gray-800 ring-1 ring-black ring-opacity-5 focus:outline-none z-10" static>
|
||||||
{options.map((option) => DropdownItem({ option, currentInterval, updateInterval }))}
|
{options.map(renderDropdownItem)}
|
||||||
</Menu.Items>
|
</Menu.Items>
|
||||||
</Transition>
|
</Transition>
|
||||||
</>
|
</>
|
||||||
|
247
assets/js/dashboard/stats/graph/line-graph.js
Normal file
247
assets/js/dashboard/stats/graph/line-graph.js
Normal file
@ -0,0 +1,247 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { withRouter } from 'react-router-dom'
|
||||||
|
import Chart from 'chart.js/auto';
|
||||||
|
import { navigateToQuery } from '../../query'
|
||||||
|
import GraphTooltip from './graph-tooltip'
|
||||||
|
import { buildDataSet, METRIC_LABELS, METRIC_FORMATTER } from './graph-util'
|
||||||
|
import dateFormatter from './date-formatter';
|
||||||
|
import FadeIn from '../../fade-in';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
|
||||||
|
const calculateMaximumY = function(dataset) {
|
||||||
|
const yAxisValues = dataset
|
||||||
|
.flatMap((item) => item.data)
|
||||||
|
.map((item) => item || 0)
|
||||||
|
|
||||||
|
if (yAxisValues) {
|
||||||
|
return Math.max(...yAxisValues)
|
||||||
|
} else {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class LineGraph extends React.Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.regenerateChart = this.regenerateChart.bind(this);
|
||||||
|
this.updateWindowDimensions = this.updateWindowDimensions.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
getGraphMetric() {
|
||||||
|
let metric = this.props.graphData.metric
|
||||||
|
|
||||||
|
if (metric == 'visitors' && this.props.query.filters.goal) {
|
||||||
|
return 'conversions'
|
||||||
|
} else {
|
||||||
|
return metric
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
regenerateChart() {
|
||||||
|
const { graphData, query } = this.props
|
||||||
|
const metric = this.getGraphMetric()
|
||||||
|
const graphEl = document.getElementById("main-graph-canvas")
|
||||||
|
this.ctx = graphEl.getContext('2d');
|
||||||
|
const dataSet = buildDataSet(graphData.plot, graphData.comparison_plot, graphData.present_index, this.ctx, METRIC_LABELS[metric])
|
||||||
|
|
||||||
|
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, query)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
onResize: this.updateWindowDimensions,
|
||||||
|
elements: { line: { tension: 0 }, point: { radius: 0 } },
|
||||||
|
onClick: this.maybeHopToHoveredPeriod.bind(this),
|
||||||
|
scale: {
|
||||||
|
ticks: { precision: 0, maxTicksLimit: 8 }
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
min: 0,
|
||||||
|
suggestedMax: calculateMaximumY(dataSet),
|
||||||
|
ticks: {
|
||||||
|
callback: METRIC_FORMATTER[metric],
|
||||||
|
color: this.props.darkTheme ? 'rgb(243, 244, 246)' : undefined
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
zeroLineColor: 'transparent',
|
||||||
|
drawBorder: false,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
yComparison: {
|
||||||
|
min: 0,
|
||||||
|
suggestedMax: calculateMaximumY(dataSet),
|
||||||
|
display: false,
|
||||||
|
grid: { display: false },
|
||||||
|
},
|
||||||
|
x: {
|
||||||
|
grid: { display: false },
|
||||||
|
ticks: {
|
||||||
|
callback: function(val, _index, _ticks) {
|
||||||
|
if (this.getLabelForValue(val) == "__blank__") return ""
|
||||||
|
|
||||||
|
const hasMultipleYears =
|
||||||
|
graphData.labels
|
||||||
|
.filter((date) => typeof date === 'string')
|
||||||
|
.map(date => date.split('-')[0])
|
||||||
|
.filter((value, index, list) => list.indexOf(value) === index)
|
||||||
|
.length > 1
|
||||||
|
|
||||||
|
if (graphData.interval === 'hour' && query.period !== 'day') {
|
||||||
|
const date = dateFormatter({
|
||||||
|
interval: "date",
|
||||||
|
longForm: false,
|
||||||
|
period: query.period,
|
||||||
|
shouldShowYear: hasMultipleYears,
|
||||||
|
})(this.getLabelForValue(val))
|
||||||
|
|
||||||
|
const hour = dateFormatter({
|
||||||
|
interval: graphData.interval,
|
||||||
|
longForm: false,
|
||||||
|
period: query.period,
|
||||||
|
shouldShowYear: hasMultipleYears,
|
||||||
|
})(this.getLabelForValue(val))
|
||||||
|
|
||||||
|
// Returns a combination of date and hour. This is because
|
||||||
|
// small intervals like hour may return multiple days
|
||||||
|
// depending on the query period.
|
||||||
|
return `${date}, ${hour}`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (graphData.interval === 'minute' && query.period !== 'realtime') {
|
||||||
|
return dateFormatter({
|
||||||
|
interval: "hour", longForm: false, period: query.period,
|
||||||
|
})(this.getLabelForValue(val))
|
||||||
|
}
|
||||||
|
|
||||||
|
return dateFormatter({
|
||||||
|
interval: graphData.interval, longForm: false, period: query.period, shouldShowYear: hasMultipleYears,
|
||||||
|
})(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.graphData) {
|
||||||
|
this.chart = this.regenerateChart();
|
||||||
|
}
|
||||||
|
window.addEventListener('mousemove', this.repositionTooltip);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate(prevProps) {
|
||||||
|
const { graphData, darkTheme } = this.props;
|
||||||
|
const tooltip = document.getElementById('chartjs-tooltip');
|
||||||
|
|
||||||
|
if (
|
||||||
|
graphData !== prevProps.graphData ||
|
||||||
|
darkTheme !== prevProps.darkTheme
|
||||||
|
) {
|
||||||
|
|
||||||
|
if (graphData) {
|
||||||
|
if (this.chart) {
|
||||||
|
this.chart.destroy();
|
||||||
|
}
|
||||||
|
this.chart = this.regenerateChart();
|
||||||
|
this.chart.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tooltip) {
|
||||||
|
tooltip.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!graphData) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
maybeHopToHoveredPeriod(e) {
|
||||||
|
const element = this.chart.getElementsAtEventForMode(e, 'index', { intersect: false })[0]
|
||||||
|
const date = this.props.graphData.labels[element.index] || this.props.graphData.comparison_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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { graphData } = this.props
|
||||||
|
const canvasClass = classNames('mt-4 select-none', { 'cursor-pointer': !['minute', 'hour'].includes(graphData?.interval) })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FadeIn show={graphData}>
|
||||||
|
<div className="relative h-96 print:h-auto print:pb-8 w-full z-0">
|
||||||
|
<canvas id="main-graph-canvas" className={canvasClass}></canvas>
|
||||||
|
</div>
|
||||||
|
</FadeIn>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withRouter(LineGraph)
|
17
assets/js/dashboard/stats/graph/sampling-notice.js
Normal file
17
assets/js/dashboard/stats/graph/sampling-notice.js
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import React from "react"
|
||||||
|
|
||||||
|
export default function SamplingNotice({topStatData}) {
|
||||||
|
const samplePercent = topStatData?.samplePercent
|
||||||
|
|
||||||
|
if (samplePercent && 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>
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
53
assets/js/dashboard/stats/graph/stats-export.js
Normal file
53
assets/js/dashboard/stats/graph/stats-export.js
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import React, { useState } from "react"
|
||||||
|
import * as api from '../../api'
|
||||||
|
import { getCurrentInterval } from "./interval-picker"
|
||||||
|
|
||||||
|
export default function StatsExport({site, query}) {
|
||||||
|
const [exporting, setExporting] = useState(false)
|
||||||
|
|
||||||
|
function startExport() {
|
||||||
|
setExporting(true)
|
||||||
|
document.cookie = "exporting="
|
||||||
|
pollExportReady()
|
||||||
|
}
|
||||||
|
|
||||||
|
function pollExportReady() {
|
||||||
|
if (document.cookie.includes('exporting')) {
|
||||||
|
setTimeout(pollExportReady, 1000)
|
||||||
|
} else {
|
||||||
|
setExporting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLoading() {
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderExportLink() {
|
||||||
|
const interval = getCurrentInterval(site, query)
|
||||||
|
const queryParams = api.serializeQuery(query, [{ interval }])
|
||||||
|
const endpoint = `/${encodeURIComponent(site.domain)}/export${queryParams}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a href={endpoint} download onClick={startExport}>
|
||||||
|
<svg className="absolute text-gray-700 feather dark:text-gray-300 hover:text-indigo-600 dark:hover:text-indigo-600" 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-4 h-4 mx-2">
|
||||||
|
{exporting && renderLoading()}
|
||||||
|
{!exporting && renderExportLink()}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
@ -3,8 +3,9 @@ import { Tooltip } from '../../util/tooltip'
|
|||||||
import { SecondsSinceLastLoad } from '../../util/seconds-since-last-load'
|
import { SecondsSinceLastLoad } from '../../util/seconds-since-last-load'
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import numberFormatter, { durationFormatter } from '../../util/number-formatter'
|
import numberFormatter, { durationFormatter } from '../../util/number-formatter'
|
||||||
import { METRIC_MAPPING } from './graph-util'
|
import * as storage from '../../util/storage'
|
||||||
import { formatDateRange } from '../../util/date.js'
|
import { formatDateRange } from '../../util/date.js'
|
||||||
|
import { getGraphableMetrics } from "./graph-util.js";
|
||||||
|
|
||||||
function Maybe({condition, children}) {
|
function Maybe({condition, children}) {
|
||||||
if (condition) {
|
if (condition) {
|
||||||
@ -14,99 +15,103 @@ function Maybe({condition, children}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class TopStats extends React.Component {
|
function renderPercentageComparison(name, comparison, forceDarkBg = false) {
|
||||||
renderPercentageComparison(name, comparison, forceDarkBg = false) {
|
const formattedComparison = numberFormatter(Math.abs(comparison))
|
||||||
const formattedComparison = numberFormatter(Math.abs(comparison))
|
|
||||||
|
|
||||||
const defaultClassName = classNames({
|
const defaultClassName = classNames({
|
||||||
"pl-2 text-xs dark:text-gray-100": !forceDarkBg,
|
"pl-2 text-xs dark:text-gray-100": !forceDarkBg,
|
||||||
"pl-2 text-xs text-gray-100": forceDarkBg
|
"pl-2 text-xs text-gray-100": forceDarkBg
|
||||||
})
|
})
|
||||||
|
|
||||||
const noChangeClassName = classNames({
|
const noChangeClassName = classNames({
|
||||||
"pl-2 text-xs text-gray-700 dark:text-gray-300": !forceDarkBg,
|
"pl-2 text-xs text-gray-700 dark:text-gray-300": !forceDarkBg,
|
||||||
"pl-2 text-xs text-gray-300": forceDarkBg
|
"pl-2 text-xs text-gray-300": forceDarkBg
|
||||||
})
|
})
|
||||||
|
|
||||||
if (comparison > 0) {
|
if (comparison > 0) {
|
||||||
const color = name === 'Bounce rate' ? 'text-red-400' : 'text-green-500'
|
const color = name === 'Bounce rate' ? 'text-red-400' : 'text-green-500'
|
||||||
return <span className={defaultClassName}><span className={color + ' font-bold'}>↑</span> {formattedComparison}%</span>
|
return <span className={defaultClassName}><span className={color + ' font-bold'}>↑</span> {formattedComparison}%</span>
|
||||||
} else if (comparison < 0) {
|
} else if (comparison < 0) {
|
||||||
const color = name === 'Bounce rate' ? 'text-green-500' : 'text-red-400'
|
const color = name === 'Bounce rate' ? 'text-green-500' : 'text-red-400'
|
||||||
return <span className={defaultClassName}><span className={color + ' font-bold'}>↓</span> {formattedComparison}%</span>
|
return <span className={defaultClassName}><span className={color + ' font-bold'}>↓</span> {formattedComparison}%</span>
|
||||||
} else if (comparison === 0) {
|
} else if (comparison === 0) {
|
||||||
return <span className={noChangeClassName}>〰 0%</span>
|
return <span className={noChangeClassName}>〰 0%</span>
|
||||||
} else {
|
} else {
|
||||||
return null
|
return null
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
topStatNumberShort(name, value) {
|
function topStatNumberShort(name, value) {
|
||||||
if (['visit duration', 'time on page'].includes(name.toLowerCase())) {
|
if (['visit duration', 'time on page'].includes(name.toLowerCase())) {
|
||||||
return durationFormatter(value)
|
return durationFormatter(value)
|
||||||
} else if (['bounce rate', 'conversion rate'].includes(name.toLowerCase())) {
|
} else if (['bounce rate', 'conversion rate'].includes(name.toLowerCase())) {
|
||||||
return value + '%'
|
return value + '%'
|
||||||
} else if (['average revenue', 'total revenue'].includes(name.toLowerCase())) {
|
} else if (['average revenue', 'total revenue'].includes(name.toLowerCase())) {
|
||||||
return value?.short
|
return value?.short
|
||||||
} else {
|
} else {
|
||||||
return numberFormatter(value)
|
return numberFormatter(value)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
topStatNumberLong(name, value) {
|
function topStatNumberLong(name, value) {
|
||||||
if (['visit duration', 'time on page'].includes(name.toLowerCase())) {
|
if (['visit duration', 'time on page'].includes(name.toLowerCase())) {
|
||||||
return durationFormatter(value)
|
return durationFormatter(value)
|
||||||
} else if (['bounce rate', 'conversion rate'].includes(name.toLowerCase())) {
|
} else if (['bounce rate', 'conversion rate'].includes(name.toLowerCase())) {
|
||||||
return value + '%'
|
return value + '%'
|
||||||
} else if (['average revenue', 'total revenue'].includes(name.toLowerCase())) {
|
} else if (['average revenue', 'total revenue'].includes(name.toLowerCase())) {
|
||||||
return value?.long
|
return value?.long
|
||||||
} else {
|
} else {
|
||||||
return (value || 0).toLocaleString()
|
return (value || 0).toLocaleString()
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
topStatTooltip(stat, query) {
|
export default function TopStats(props) {
|
||||||
|
const {site, query, data, onMetricUpdate, tooltipBoundary, lastLoadTimestamp} = props
|
||||||
|
|
||||||
|
function tooltip(stat) {
|
||||||
let statName = stat.name.toLowerCase()
|
let statName = stat.name.toLowerCase()
|
||||||
statName = stat.value === 1 ? statName.slice(0, -1) : statName
|
statName = stat.value === 1 ? statName.slice(0, -1) : statName
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{query.comparison && <div className="whitespace-nowrap">
|
{query.comparison && <div className="whitespace-nowrap">
|
||||||
{this.topStatNumberLong(stat.name, stat.value)} vs. {this.topStatNumberLong(stat.name, stat.comparison_value)} {statName}
|
{topStatNumberLong(stat.name, stat.value)} vs. {topStatNumberLong(stat.name, stat.comparison_value)} {statName}
|
||||||
<span className="ml-2">{this.renderPercentageComparison(stat.name, stat.change, true)}</span>
|
<span className="ml-2">{renderPercentageComparison(stat.name, stat.change, true)}</span>
|
||||||
</div>}
|
</div>}
|
||||||
|
|
||||||
{!query.comparison && <div className="whitespace-nowrap">
|
{!query.comparison && <div className="whitespace-nowrap">
|
||||||
{this.topStatNumberLong(stat.name, stat.value)} {statName}
|
{topStatNumberLong(stat.name, stat.value)} {statName}
|
||||||
</div>}
|
</div>}
|
||||||
|
|
||||||
{stat.name === 'Current visitors' && <p className="font-normal text-xs">Last updated <SecondsSinceLastLoad lastLoadTimestamp={this.props.lastLoadTimestamp}/>s ago</p>}
|
{stat.name === 'Current visitors' && <p className="font-normal text-xs">Last updated <SecondsSinceLastLoad lastLoadTimestamp={lastLoadTimestamp}/>s ago</p>}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
canMetricBeGraphed(stat) {
|
function canMetricBeGraphed(stat) {
|
||||||
const isTotalUniqueVisitors = this.props.query.filters.goal && stat.name === 'Unique visitors'
|
const graphableMetrics = getGraphableMetrics(query, site)
|
||||||
const isKnownMetric = Object.keys(METRIC_MAPPING).includes(stat.name)
|
return stat.graph_metric && graphableMetrics.includes(stat.graph_metric)
|
||||||
|
|
||||||
return isKnownMetric && !isTotalUniqueVisitors
|
|
||||||
}
|
}
|
||||||
|
|
||||||
maybeUpdateMetric(stat) {
|
function maybeUpdateMetric(stat) {
|
||||||
if (this.canMetricBeGraphed(stat)) {
|
if (canMetricBeGraphed(stat)) {
|
||||||
this.props.updateMetric(METRIC_MAPPING[stat.name])
|
storage.setItem(`metric__${site.domain}`, stat.graph_metric)
|
||||||
|
onMetricUpdate(stat.graph_metric)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
blinkingDot() {
|
function blinkingDot() {
|
||||||
return (
|
return (
|
||||||
<div key="dot" className="block pulsating-circle" style={{ left: '125px', top: '52px' }}></div>
|
<div key="dot" className="block pulsating-circle" style={{ left: '125px', top: '52px' }}></div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
renderStatName(stat) {
|
function getStoredMetric() {
|
||||||
const { metric } = this.props
|
return storage.getItem(`metric__${site.domain}`)
|
||||||
const isSelected = metric === METRIC_MAPPING[stat.name]
|
}
|
||||||
|
|
||||||
|
function renderStatName(stat) {
|
||||||
|
const isSelected = stat.graph_metric === getStoredMetric()
|
||||||
|
|
||||||
const [statDisplayName, statExtraName] = stat.name.split(/(\(.+\))/g)
|
const [statDisplayName, statExtraName] = stat.name.split(/(\(.+\))/g)
|
||||||
|
|
||||||
@ -123,48 +128,45 @@ export default class TopStats extends React.Component {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
function renderStat(stat, index) {
|
||||||
const { topStatData, query, site } = this.props
|
const className = classNames('px-4 md:px-6 w-1/2 my-4 lg:w-auto group select-none', {
|
||||||
|
'cursor-pointer': canMetricBeGraphed(stat),
|
||||||
const stats = topStatData && topStatData.top_stats.map((stat, index) => {
|
'lg:border-l border-gray-300': index > 0,
|
||||||
|
'border-r lg:border-r-0': index % 2 === 0
|
||||||
const className = classNames('px-4 md:px-6 w-1/2 my-4 lg:w-auto group select-none', {
|
|
||||||
'cursor-pointer': this.canMetricBeGraphed(stat),
|
|
||||||
'lg:border-l border-gray-300': index > 0,
|
|
||||||
'border-r lg:border-r-0': index % 2 === 0
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tooltip key={stat.name} info={this.topStatTooltip(stat, query)} className={className} onClick={() => { this.maybeUpdateMetric(stat) }} boundary={this.props.tooltipBoundary}>
|
|
||||||
{this.renderStatName(stat)}
|
|
||||||
<div className="my-1 space-y-2">
|
|
||||||
<div>
|
|
||||||
<span className="flex items-center justify-between whitespace-nowrap">
|
|
||||||
<p className="font-bold text-xl dark:text-gray-100" id={METRIC_MAPPING[stat.name]}>{this.topStatNumberShort(stat.name, stat.value)}</p>
|
|
||||||
<Maybe condition={!query.comparison}>
|
|
||||||
{ this.renderPercentageComparison(stat.name, stat.change) }
|
|
||||||
</Maybe>
|
|
||||||
</span>
|
|
||||||
<Maybe condition={query.comparison}>
|
|
||||||
<p className="text-xs dark:text-gray-100">{ formatDateRange(site, topStatData.from, topStatData.to) }</p>
|
|
||||||
</Maybe>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Maybe condition={query.comparison}>
|
|
||||||
<div>
|
|
||||||
<p className="font-bold text-xl text-gray-500 dark:text-gray-400">{ this.topStatNumberShort(stat.name, stat.comparison_value) }</p>
|
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-400">{ formatDateRange(site, topStatData.comparing_from, topStatData.comparing_to) }</p>
|
|
||||||
</div>
|
|
||||||
</Maybe>
|
|
||||||
</div>
|
|
||||||
</Tooltip>
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (stats && query && query.period === 'realtime') {
|
return (
|
||||||
stats.push(this.blinkingDot())
|
<Tooltip key={stat.name} info={tooltip(stat, query)} className={className} onClick={() => { maybeUpdateMetric(stat) }} boundary={tooltipBoundary}>
|
||||||
}
|
{renderStatName(stat)}
|
||||||
|
<div className="my-1 space-y-2">
|
||||||
|
<div>
|
||||||
|
<span className="flex items-center justify-between whitespace-nowrap">
|
||||||
|
<p className="font-bold text-xl dark:text-gray-100" id={stat.graph_metric}>{topStatNumberShort(stat.name, stat.value)}</p>
|
||||||
|
<Maybe condition={!query.comparison}>
|
||||||
|
{ renderPercentageComparison(stat.name, stat.change) }
|
||||||
|
</Maybe>
|
||||||
|
</span>
|
||||||
|
<Maybe condition={query.comparison}>
|
||||||
|
<p className="text-xs dark:text-gray-100">{ formatDateRange(site, data.from, data.to) }</p>
|
||||||
|
</Maybe>
|
||||||
|
</div>
|
||||||
|
|
||||||
return stats || null;
|
<Maybe condition={query.comparison}>
|
||||||
|
<div>
|
||||||
|
<p className="font-bold text-xl text-gray-500 dark:text-gray-400">{ topStatNumberShort(stat.name, stat.comparison_value) }</p>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">{ formatDateRange(site, data.comparing_from, data.comparing_to) }</p>
|
||||||
|
</div>
|
||||||
|
</Maybe>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const stats = data && data.top_stats.map(renderStat)
|
||||||
|
|
||||||
|
if (stats && query.period === 'realtime') {
|
||||||
|
stats.push(blinkingDot())
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats || null;
|
||||||
}
|
}
|
||||||
|
@ -1,532 +1,153 @@
|
|||||||
import React from 'react';
|
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { withRouter, Link } from 'react-router-dom'
|
|
||||||
import Chart from 'chart.js/auto';
|
|
||||||
import { navigateToQuery } from '../../query'
|
|
||||||
import * as api from '../../api'
|
import * as api from '../../api'
|
||||||
import * as storage from '../../util/storage'
|
import * as storage from '../../util/storage'
|
||||||
import LazyLoader from '../../components/lazy-loader'
|
import { getGraphableMetrics } from './graph-util'
|
||||||
import GraphTooltip from './graph-tooltip'
|
|
||||||
import { buildDataSet, METRIC_MAPPING, METRIC_LABELS, METRIC_FORMATTER, LoadingState } from './graph-util'
|
|
||||||
import dateFormatter from './date-formatter';
|
|
||||||
import TopStats from './top-stats';
|
import TopStats from './top-stats';
|
||||||
import { IntervalPicker, getStoredInterval, storeInterval } from './interval-picker';
|
import { IntervalPicker, getCurrentInterval } from './interval-picker'
|
||||||
|
import StatsExport from './stats-export'
|
||||||
|
import WithImportedSwitch from './with-imported-switch';
|
||||||
|
import SamplingNotice from './sampling-notice';
|
||||||
import FadeIn from '../../fade-in';
|
import FadeIn from '../../fade-in';
|
||||||
import * as url from '../../util/url'
|
import * as url from '../../util/url'
|
||||||
import classNames from 'classnames';
|
|
||||||
import { monthsBetweenDates, parseNaiveDate, isBefore } from '../../util/date'
|
|
||||||
import { isComparisonEnabled } from '../../comparison-input'
|
import { isComparisonEnabled } from '../../comparison-input'
|
||||||
import { BarsArrowUpIcon } from '@heroicons/react/20/solid'
|
import LineGraphWithRouter from './line-graph'
|
||||||
|
|
||||||
const calculateMaximumY = function(dataset) {
|
function fetchTopStats(site, query) {
|
||||||
const yAxisValues = dataset
|
const q = { ...query }
|
||||||
.flatMap((item) => item.data)
|
|
||||||
.map((item) => item || 0)
|
if (!isComparisonEnabled(q.comparison)) {
|
||||||
|
q.comparison = 'previous_period'
|
||||||
if (yAxisValues) {
|
|
||||||
return Math.max(...yAxisValues)
|
|
||||||
} else {
|
|
||||||
return 1
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return api.get(url.apiPath(site, '/top-stats'), q)
|
||||||
}
|
}
|
||||||
|
|
||||||
class LineGraph extends React.Component {
|
function fetchMainGraph(site, query, metric, interval) {
|
||||||
constructor(props) {
|
const params = {metric, interval}
|
||||||
super(props);
|
return api.get(url.apiPath(site, '/main-graph'), query, params)
|
||||||
this.boundary = React.createRef()
|
}
|
||||||
this.regenerateChart = this.regenerateChart.bind(this);
|
|
||||||
this.updateWindowDimensions = this.updateWindowDimensions.bind(this);
|
|
||||||
this.state = {
|
|
||||||
exported: false
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
regenerateChart() {
|
export default function VisitorGraph(props) {
|
||||||
const { graphData, metric, query } = this.props
|
const {site, query, lastLoadTimestamp} = props
|
||||||
const graphEl = document.getElementById("main-graph-canvas")
|
const isRealtime = query.period === 'realtime'
|
||||||
this.ctx = graphEl.getContext('2d');
|
const isDarkTheme = document.querySelector('html').classList.contains('dark') || false
|
||||||
const dataSet = buildDataSet(graphData.plot, graphData.comparison_plot, graphData.present_index, this.ctx, METRIC_LABELS[metric])
|
|
||||||
|
|
||||||
return new Chart(this.ctx, {
|
const topStatsBoundary = useRef(null)
|
||||||
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, query)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
responsive: true,
|
|
||||||
maintainAspectRatio: false,
|
|
||||||
onResize: this.updateWindowDimensions,
|
|
||||||
elements: { line: { tension: 0 }, point: { radius: 0 } },
|
|
||||||
onClick: this.onClick.bind(this),
|
|
||||||
scale: {
|
|
||||||
ticks: { precision: 0, maxTicksLimit: 8 }
|
|
||||||
},
|
|
||||||
scales: {
|
|
||||||
y: {
|
|
||||||
min: 0,
|
|
||||||
suggestedMax: calculateMaximumY(dataSet),
|
|
||||||
ticks: {
|
|
||||||
callback: METRIC_FORMATTER[metric],
|
|
||||||
color: this.props.darkTheme ? 'rgb(243, 244, 246)' : undefined
|
|
||||||
},
|
|
||||||
grid: {
|
|
||||||
zeroLineColor: 'transparent',
|
|
||||||
drawBorder: false,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
yComparison: {
|
|
||||||
min: 0,
|
|
||||||
suggestedMax: calculateMaximumY(dataSet),
|
|
||||||
display: false,
|
|
||||||
grid: { display: false },
|
|
||||||
},
|
|
||||||
x: {
|
|
||||||
grid: { display: false },
|
|
||||||
ticks: {
|
|
||||||
callback: function(val, _index, _ticks) {
|
|
||||||
if (this.getLabelForValue(val) == "__blank__") return ""
|
|
||||||
|
|
||||||
const hasMultipleYears =
|
const [topStatData, setTopStatData] = useState(null)
|
||||||
graphData.labels
|
const [topStatsLoading, setTopStatsLoading] = useState(true)
|
||||||
.filter((date) => typeof date === 'string')
|
const [graphData, setGraphData] = useState(null)
|
||||||
.map(date => date.split('-')[0])
|
const [graphLoading, setGraphLoading] = useState(true)
|
||||||
.filter((value, index, list) => list.indexOf(value) === index)
|
|
||||||
.length > 1
|
|
||||||
|
|
||||||
if (graphData.interval === 'hour' && query.period !== 'day') {
|
// This state is explicitly meant for the situation where either graph interval
|
||||||
const date = dateFormatter({
|
// or graph metric is changed. That results in behaviour where Top Stats stay
|
||||||
interval: "date",
|
// intact, but the graph container alone will display a loading spinner for as
|
||||||
longForm: false,
|
// long as new graph data is fetched.
|
||||||
period: query.period,
|
const [graphRefreshing, setGraphRefreshing] = useState(false)
|
||||||
shouldShowYear: hasMultipleYears,
|
|
||||||
})(this.getLabelForValue(val))
|
|
||||||
|
|
||||||
const hour = dateFormatter({
|
|
||||||
interval: graphData.interval,
|
|
||||||
longForm: false,
|
|
||||||
period: query.period,
|
|
||||||
shouldShowYear: hasMultipleYears,
|
|
||||||
})(this.getLabelForValue(val))
|
|
||||||
|
|
||||||
// Returns a combination of date and hour. This is because
|
const onIntervalUpdate = useCallback((newInterval) => {
|
||||||
// small intervals like hour may return multiple days
|
setGraphData(null)
|
||||||
// depending on the query period.
|
setGraphRefreshing(true)
|
||||||
return `${date}, ${hour}`
|
fetchGraphData(getStoredMetric(), newInterval)
|
||||||
}
|
}, [query])
|
||||||
|
|
||||||
if (graphData.interval === 'minute' && query.period !== 'realtime') {
|
const onMetricUpdate = useCallback((newMetric) => {
|
||||||
return dateFormatter({
|
setGraphData(null)
|
||||||
interval: "hour", longForm: false, period: query.period,
|
setGraphRefreshing(true)
|
||||||
})(this.getLabelForValue(val))
|
fetchGraphData(newMetric, getCurrentInterval(site, query))
|
||||||
}
|
}, [query])
|
||||||
|
|
||||||
return dateFormatter({
|
useEffect(() => {
|
||||||
interval: graphData.interval, longForm: false, period: query.period, shouldShowYear: hasMultipleYears,
|
setTopStatData(null)
|
||||||
})(this.getLabelForValue(val))
|
setTopStatsLoading(true)
|
||||||
},
|
setGraphData(null)
|
||||||
color: this.props.darkTheme ? 'rgb(243, 244, 246)' : undefined
|
setGraphLoading(true)
|
||||||
}
|
fetchTopStatsAndGraphData()
|
||||||
}
|
|
||||||
},
|
|
||||||
interaction: {
|
|
||||||
mode: 'index',
|
|
||||||
intersect: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
repositionTooltip(e) {
|
if (isRealtime) {
|
||||||
const tooltipEl = document.getElementById('chartjs-tooltip');
|
document.addEventListener('tick', fetchTopStatsAndGraphData)
|
||||||
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.graphData) {
|
|
||||||
this.chart = this.regenerateChart();
|
|
||||||
}
|
|
||||||
window.addEventListener('mousemove', this.repositionTooltip);
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidUpdate(prevProps) {
|
|
||||||
const { graphData, darkTheme } = this.props;
|
|
||||||
const tooltip = document.getElementById('chartjs-tooltip');
|
|
||||||
|
|
||||||
if (
|
|
||||||
graphData !== prevProps.graphData ||
|
|
||||||
darkTheme !== prevProps.darkTheme
|
|
||||||
) {
|
|
||||||
|
|
||||||
if (graphData) {
|
|
||||||
if (this.chart) {
|
|
||||||
this.chart.destroy();
|
|
||||||
}
|
|
||||||
this.chart = this.regenerateChart();
|
|
||||||
this.chart.update();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tooltip) {
|
|
||||||
tooltip.style.display = 'none';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!graphData) {
|
return () => {
|
||||||
if (this.chart) {
|
document.removeEventListener('tick', fetchTopStatsAndGraphData)
|
||||||
this.chart.destroy();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tooltip) {
|
|
||||||
tooltip.style.display = 'none';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}, [query])
|
||||||
|
|
||||||
componentWillUnmount() {
|
useEffect(() => {
|
||||||
// Ensure that the tooltip doesn't hang around when we are loading more data
|
if (topStatData) { storeTopStatsContainerHeight() }
|
||||||
const tooltip = document.getElementById('chartjs-tooltip');
|
}, [topStatData])
|
||||||
if (tooltip) {
|
|
||||||
tooltip.style.opacity = 0;
|
|
||||||
tooltip.style.display = 'none';
|
|
||||||
}
|
|
||||||
window.removeEventListener('mousemove', this.repositionTooltip)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
function fetchTopStatsAndGraphData() {
|
||||||
* The current ticks' limits are set to treat iPad (regular/Mini/Pro) as a regular screen.
|
fetchTopStats(site, query)
|
||||||
* @param {*} chart - The chart instance.
|
.then((res) => {
|
||||||
* @param {*} dimensions - An object containing the new dimensions *of the chart.*
|
setTopStatData(res)
|
||||||
*/
|
setTopStatsLoading(false)
|
||||||
updateWindowDimensions(chart, dimensions) {
|
})
|
||||||
chart.options.scales.x.ticks.maxTicksLimit = dimensions.width < 720 ? 5 : 8
|
|
||||||
}
|
let metric = getStoredMetric()
|
||||||
|
const availableMetrics = getGraphableMetrics(query, site)
|
||||||
onClick(e) {
|
|
||||||
const element = this.chart.getElementsAtEventForMode(e, 'index', { intersect: false })[0]
|
if (!availableMetrics.includes(metric)) {
|
||||||
const date = this.props.graphData.labels[element.index] || this.props.graphData.comparison_labels[element.index]
|
metric = availableMetrics[0]
|
||||||
|
storage.setItem(`metric__${site.domain}`, metric)
|
||||||
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 interval = this.props.graphData?.interval
|
|
||||||
const queryParams = api.serializeQuery(this.props.query, [{ interval }])
|
|
||||||
const endpoint = `/${encodeURIComponent(this.props.site.domain)}/export${queryParams}`
|
|
||||||
|
|
||||||
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 hover:text-indigo-600 dark:hover:text-indigo-600" 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() {
|
|
||||||
if (!this.props.topStatData?.imports_exist) return
|
|
||||||
|
|
||||||
const isBeforeNativeStats = (date) => {
|
|
||||||
if (!date) return false
|
|
||||||
|
|
||||||
const nativeStatsBegin = parseNaiveDate(this.props.site.nativeStatsBegin)
|
|
||||||
const parsedDate = parseNaiveDate(date)
|
|
||||||
|
|
||||||
return isBefore(parsedDate, nativeStatsBegin, "day")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const isQueryingImportedPeriod = isBeforeNativeStats(this.props.topStatData.from)
|
const interval = getCurrentInterval(site, query)
|
||||||
const isComparingImportedPeriod = isBeforeNativeStats(this.props.topStatData.comparing_from)
|
|
||||||
|
|
||||||
if (isQueryingImportedPeriod || isComparingImportedPeriod) {
|
fetchGraphData(metric, interval)
|
||||||
const withImported = this.props.topStatData.with_imported;
|
}
|
||||||
const toggleColor = withImported ? " dark:text-gray-300 text-gray-700" : " dark:text-gray-500 text-gray-400"
|
|
||||||
const target = url.setQuery('with_imported', !withImported)
|
|
||||||
const tip = withImported ? "" : "do not ";
|
|
||||||
|
|
||||||
return (
|
function fetchGraphData(metric, interval) {
|
||||||
<Link to={target} className="w-4 h-4 mx-2">
|
fetchMainGraph(site, query, metric, interval)
|
||||||
<div tooltip={`Stats ${tip}include imported data.`} className="cursor-pointer w-4 h-4">
|
.then((res) => {
|
||||||
<BarsArrowUpIcon className={"absolute " + toggleColor} />
|
setGraphData(res)
|
||||||
</div>
|
setGraphLoading(false)
|
||||||
</Link>
|
setGraphRefreshing(false)
|
||||||
)
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getStoredMetric() {
|
||||||
|
return storage.getItem(`metric__${site.domain}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function storeTopStatsContainerHeight() {
|
||||||
|
storage.setItem(`topStatsHeight__${site.domain}`, document.getElementById('top-stats-container').clientHeight)
|
||||||
}
|
}
|
||||||
|
|
||||||
// This function is used for maintaining the main-graph/top-stats container height in the
|
// This function is used for maintaining the main-graph/top-stats container height in the
|
||||||
// loading process. The container height depends on how many top stat metrics are returned
|
// loading process. The container height depends on how many top stat metrics are returned
|
||||||
// from the API, but in the loading state, we don't know that yet. We can use localStorage
|
// from the API, but in the loading state, we don't know that yet. We can use localStorage
|
||||||
// to keep track of the Top Stats container height.
|
// to keep track of the Top Stats container height.
|
||||||
getTopStatsHeight() {
|
function getTopStatsHeight() {
|
||||||
if (this.props.topStatData) {
|
if (topStatData) {
|
||||||
return 'auto'
|
return 'auto'
|
||||||
} else {
|
} else {
|
||||||
return `${storage.getItem(`topStatsHeight__${this.props.site.domain}`) || 89}px`
|
return `${storage.getItem(`topStatsHeight__${site.domain}`) || 89}px`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
|
||||||
const { mainGraphRefreshing, updateMetric, updateInterval, metric, topStatData, query, site, graphData, lastLoadTimestamp } = this.props
|
|
||||||
const canvasClass = classNames('mt-4 select-none', { 'cursor-pointer': !['minute', 'hour'].includes(graphData?.interval) })
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className={"relative w-full mt-2 bg-white rounded shadow-xl dark:bg-gray-825"}>
|
||||||
<div id="top-stats-container" className="flex flex-wrap" ref={this.boundary} style={{ height: this.getTopStatsHeight() }}>
|
{(topStatsLoading || graphLoading) && renderLoader()}
|
||||||
<TopStats site={site} query={query} metric={metric} updateMetric={updateMetric} topStatData={topStatData} tooltipBoundary={this.boundary.current} lastLoadTimestamp={lastLoadTimestamp} />
|
<FadeIn show={!(topStatsLoading || graphLoading)}>
|
||||||
|
<div id="top-stats-container" className="flex flex-wrap" ref={topStatsBoundary} style={{ height: getTopStatsHeight() }}>
|
||||||
|
<TopStats site={site} query={query} data={topStatData} onMetricUpdate={onMetricUpdate} tooltipBoundary={topStatsBoundary.current} lastLoadTimestamp={lastLoadTimestamp} />
|
||||||
</div>
|
</div>
|
||||||
<div className="relative px-2">
|
<div className="relative px-2">
|
||||||
{mainGraphRefreshing && renderLoader()}
|
{graphRefreshing && renderLoader()}
|
||||||
<div className="absolute right-4 -top-8 py-1 flex items-center">
|
<div className="absolute right-4 -top-8 py-1 flex items-center">
|
||||||
{this.downloadLink()}
|
{!isRealtime && <StatsExport site={site} query={query} />}
|
||||||
{this.samplingNotice()}
|
<SamplingNotice samplePercent={topStatData}/>
|
||||||
{this.importedNotice()}
|
<WithImportedSwitch site={site} topStatData={topStatData} />
|
||||||
<IntervalPicker site={site} query={query} graphData={graphData} metric={metric} updateInterval={updateInterval} />
|
<IntervalPicker site={site} query={query} onIntervalUpdate={onIntervalUpdate} />
|
||||||
</div>
|
</div>
|
||||||
<FadeIn show={graphData}>
|
<LineGraphWithRouter graphData={graphData} darkTheme={isDarkTheme} query={query} />
|
||||||
<div className="relative h-96 print:h-auto print:pb-8 w-full z-0">
|
|
||||||
<canvas id="main-graph-canvas" className={canvasClass}></canvas>
|
|
||||||
</div>
|
|
||||||
</FadeIn>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const LineGraphWithRouter = withRouter(LineGraph)
|
|
||||||
|
|
||||||
export default class VisitorGraph extends React.Component {
|
|
||||||
constructor(props) {
|
|
||||||
super(props)
|
|
||||||
this.state = {
|
|
||||||
topStatsLoadingState: LoadingState.loading,
|
|
||||||
mainGraphLoadingState: LoadingState.loading,
|
|
||||||
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)
|
|
||||||
this.updateInterval = this.updateInterval.bind(this)
|
|
||||||
}
|
|
||||||
|
|
||||||
isIntervalValid(interval) {
|
|
||||||
const { query, site } = this.props
|
|
||||||
const validIntervals = site.validIntervalsByPeriod[query.period] || []
|
|
||||||
|
|
||||||
return validIntervals.includes(interval)
|
|
||||||
}
|
|
||||||
|
|
||||||
getIntervalFromStorage() {
|
|
||||||
const { query, site } = this.props
|
|
||||||
let interval = getStoredInterval(query.period, site.domain)
|
|
||||||
|
|
||||||
if (interval !== "week" && interval !== "month" && query.period === "custom" && monthsBetweenDates(query.from, query.to) > 12) {
|
|
||||||
interval = "month"
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.isIntervalValid(interval)) {
|
|
||||||
return interval
|
|
||||||
} else {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updateInterval(interval) {
|
|
||||||
if (this.isIntervalValid(interval)) {
|
|
||||||
storeInterval(this.props.query.period, this.props.site.domain, interval)
|
|
||||||
this.setState({ mainGraphLoadingState: LoadingState.refreshing, graphData: null }, this.fetchGraphData)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onVisible() {
|
|
||||||
this.setState({ mainGraphLoadingState: LoadingState.loading }, this.fetchGraphData)
|
|
||||||
this.fetchTopStatData()
|
|
||||||
if (this.props.query.period === 'realtime') {
|
|
||||||
document.addEventListener('tick', this.fetchGraphData)
|
|
||||||
document.addEventListener('tick', this.fetchTopStatData)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidUpdate(prevProps, prevState) {
|
|
||||||
const { metric } = this.state;
|
|
||||||
const { query } = this.props
|
|
||||||
|
|
||||||
if (query !== prevProps.query) {
|
|
||||||
this.setState({ mainGraphLoadingState: LoadingState.loading, topStatsLoadingState: LoadingState.loading, graphData: null, topStatData: null }, this.fetchGraphData)
|
|
||||||
this.fetchTopStatData()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (metric !== prevState.metric) {
|
|
||||||
this.setState({ mainGraphLoadingState: LoadingState.refreshing }, this.fetchGraphData)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
resetMetric() {
|
|
||||||
const { topStatData } = this.state
|
|
||||||
const { query, site } = this.props
|
|
||||||
|
|
||||||
const savedMetric = storage.getItem(`metric__${site.domain}`)
|
|
||||||
const selectableMetrics = topStatData && topStatData.top_stats.map(({ name }) => METRIC_MAPPING[name]).filter(name => name)
|
|
||||||
const canSelectSavedMetric = selectableMetrics && selectableMetrics.includes(savedMetric)
|
|
||||||
|
|
||||||
if (query.filters.goal && !['conversion_rate', 'events'].includes(savedMetric)) {
|
|
||||||
this.setState({ metric: 'conversions' })
|
|
||||||
} else if (canSelectSavedMetric) {
|
|
||||||
this.setState({ metric: savedMetric })
|
|
||||||
} else {
|
|
||||||
this.setState({ metric: 'visitors' })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
componentWillUnmount() {
|
|
||||||
document.removeEventListener('tick', this.fetchGraphData)
|
|
||||||
document.removeEventListener('tick', this.fetchTopStatData)
|
|
||||||
}
|
|
||||||
|
|
||||||
storeTopStatsContainerHeight() {
|
|
||||||
storage.setItem(`topStatsHeight__${this.props.site.domain}`, document.getElementById('top-stats-container').clientHeight)
|
|
||||||
}
|
|
||||||
|
|
||||||
updateMetric(clickedMetric) {
|
|
||||||
if (this.state.metric == clickedMetric) return
|
|
||||||
|
|
||||||
storage.setItem(`metric__${this.props.site.domain}`, clickedMetric)
|
|
||||||
this.setState({ metric: clickedMetric, graphData: null })
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchGraphData() {
|
|
||||||
const url = `/api/stats/${encodeURIComponent(this.props.site.domain)}/main-graph`
|
|
||||||
let params = { metric: this.state.metric }
|
|
||||||
const interval = this.getIntervalFromStorage()
|
|
||||||
if (interval) { params.interval = interval }
|
|
||||||
|
|
||||||
api.get(url, this.props.query, params)
|
|
||||||
.then((res) => {
|
|
||||||
this.setState({ mainGraphLoadingState: LoadingState.loaded, graphData: res })
|
|
||||||
return res
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
console.log(err)
|
|
||||||
this.setState({ mainGraphLoadingState: LoadingState.loaded, graphData: false })
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchTopStatData() {
|
|
||||||
const query = { ...this.props.query }
|
|
||||||
if (!isComparisonEnabled(query.comparison)) query.comparison = 'previous_period'
|
|
||||||
|
|
||||||
api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/top-stats`, query)
|
|
||||||
.then((res) => {
|
|
||||||
this.setState({ topStatsLoadingState: LoadingState.loaded, topStatData: res }, () => {
|
|
||||||
this.storeTopStatsContainerHeight()
|
|
||||||
this.resetMetric()
|
|
||||||
})
|
|
||||||
return res
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
renderInner() {
|
|
||||||
const { query, site } = this.props;
|
|
||||||
const { graphData, metric, topStatData, topStatsLoadingState, mainGraphLoadingState } = this.state;
|
|
||||||
|
|
||||||
const theme = document.querySelector('html').classList.contains('dark') || false
|
|
||||||
|
|
||||||
const mainGraphRefreshing = (mainGraphLoadingState === LoadingState.refreshing)
|
|
||||||
const topStatAndGraphLoaded = !!(topStatData && graphData)
|
|
||||||
|
|
||||||
const shouldShow =
|
|
||||||
topStatsLoadingState === LoadingState.loaded &&
|
|
||||||
LoadingState.isLoadedOrRefreshing(mainGraphLoadingState) &&
|
|
||||||
(topStatData && mainGraphRefreshing || topStatAndGraphLoaded)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FadeIn show={shouldShow}>
|
|
||||||
<LineGraphWithRouter mainGraphRefreshing={mainGraphRefreshing} graphData={graphData} topStatData={topStatData} site={site} query={query} darkTheme={theme} metric={metric} updateMetric={this.updateMetric} updateInterval={this.updateInterval} lastLoadTimestamp={this.props.lastLoadTimestamp} />
|
|
||||||
</FadeIn>
|
</FadeIn>
|
||||||
)
|
</div>
|
||||||
}
|
)
|
||||||
|
|
||||||
render() {
|
|
||||||
const { mainGraphLoadingState, topStatsLoadingState } = this.state
|
|
||||||
|
|
||||||
const showLoader =
|
|
||||||
[mainGraphLoadingState, topStatsLoadingState].includes(LoadingState.loading) &&
|
|
||||||
mainGraphLoadingState !== LoadingState.refreshing
|
|
||||||
|
|
||||||
return (
|
|
||||||
<LazyLoader onVisible={this.onVisible}>
|
|
||||||
<div className={"relative w-full mt-2 bg-white rounded shadow-xl dark:bg-gray-825"}>
|
|
||||||
{showLoader && renderLoader()}
|
|
||||||
{this.renderInner()}
|
|
||||||
</div>
|
|
||||||
</LazyLoader>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderLoader() {
|
function renderLoader() {
|
||||||
|
40
assets/js/dashboard/stats/graph/with-imported-switch.js
Normal file
40
assets/js/dashboard/stats/graph/with-imported-switch.js
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import React from "react"
|
||||||
|
import { parseNaiveDate, isBefore } from '../../util/date'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import * as url from '../../util/url'
|
||||||
|
import { BarsArrowUpIcon } from '@heroicons/react/20/solid'
|
||||||
|
|
||||||
|
export default function WithImportedSwitch({site, topStatData}) {
|
||||||
|
if (!topStatData?.imports_exist) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBeforeNativeStats(date) {
|
||||||
|
if (!date) return false
|
||||||
|
|
||||||
|
const nativeStatsBegin = parseNaiveDate(site.nativeStatsBegin)
|
||||||
|
const parsedDate = parseNaiveDate(date)
|
||||||
|
|
||||||
|
return isBefore(parsedDate, nativeStatsBegin, "day")
|
||||||
|
}
|
||||||
|
|
||||||
|
const isQueryingImportedPeriod = isBeforeNativeStats(topStatData.from)
|
||||||
|
const isComparingImportedPeriod = isBeforeNativeStats(topStatData.comparing_from)
|
||||||
|
|
||||||
|
if (isQueryingImportedPeriod || isComparingImportedPeriod) {
|
||||||
|
const withImported = topStatData.with_imported;
|
||||||
|
const toggleColor = withImported ? " dark:text-gray-300 text-gray-700" : " dark:text-gray-500 text-gray-400"
|
||||||
|
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 imported data.`} className="cursor-pointer w-4 h-4">
|
||||||
|
<BarsArrowUpIcon className={"absolute " + toggleColor} />
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
@ -80,6 +80,14 @@ defmodule Plausible.Goals do
|
|||||||
|
|
||||||
def find_or_create(_, %{"goal_type" => "page"}), do: {:missing, "page_path"}
|
def find_or_create(_, %{"goal_type" => "page"}), do: {:missing, "page_path"}
|
||||||
|
|
||||||
|
def list_revenue_goals(site) do
|
||||||
|
from(g in Plausible.Goal,
|
||||||
|
where: g.site_id == ^site.id and not is_nil(g.currency),
|
||||||
|
select: %{event_name: g.event_name, currency: g.currency}
|
||||||
|
)
|
||||||
|
|> Plausible.Repo.all()
|
||||||
|
end
|
||||||
|
|
||||||
def for_site(site, opts \\ []) do
|
def for_site(site, opts \\ []) do
|
||||||
site
|
site
|
||||||
|> for_site_query(opts)
|
|> for_site_query(opts)
|
||||||
|
@ -129,6 +129,7 @@ defmodule PlausibleWeb.Api.StatsController do
|
|||||||
full_intervals = build_full_intervals(query, labels)
|
full_intervals = build_full_intervals(query, labels)
|
||||||
|
|
||||||
json(conn, %{
|
json(conn, %{
|
||||||
|
metric: metric,
|
||||||
plot: plot_timeseries(timeseries_result, metric),
|
plot: plot_timeseries(timeseries_result, metric),
|
||||||
labels: labels,
|
labels: labels,
|
||||||
comparison_plot: comparison_result && plot_timeseries(comparison_result, metric),
|
comparison_plot: comparison_result && plot_timeseries(comparison_result, metric),
|
||||||
@ -294,10 +295,12 @@ defmodule PlausibleWeb.Api.StatsController do
|
|||||||
},
|
},
|
||||||
%{
|
%{
|
||||||
name: "Unique conversions (last 30 min)",
|
name: "Unique conversions (last 30 min)",
|
||||||
|
graph_metric: :visitors,
|
||||||
value: unique_conversions
|
value: unique_conversions
|
||||||
},
|
},
|
||||||
%{
|
%{
|
||||||
name: "Total conversions (last 30 min)",
|
name: "Total conversions (last 30 min)",
|
||||||
|
graph_metric: :events,
|
||||||
value: total_conversions
|
value: total_conversions
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@ -320,10 +323,12 @@ defmodule PlausibleWeb.Api.StatsController do
|
|||||||
},
|
},
|
||||||
%{
|
%{
|
||||||
name: "Unique visitors (last 30 min)",
|
name: "Unique visitors (last 30 min)",
|
||||||
|
graph_metric: :visitors,
|
||||||
value: visitors
|
value: visitors
|
||||||
},
|
},
|
||||||
%{
|
%{
|
||||||
name: "Pageviews (last 30 min)",
|
name: "Pageviews (last 30 min)",
|
||||||
|
graph_metric: :pageviews,
|
||||||
value: pageviews
|
value: pageviews
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@ -340,15 +345,21 @@ defmodule PlausibleWeb.Api.StatsController do
|
|||||||
|
|
||||||
[
|
[
|
||||||
top_stats_entry(results, comparison, "Unique visitors", :total_visitors),
|
top_stats_entry(results, comparison, "Unique visitors", :total_visitors),
|
||||||
top_stats_entry(results, comparison, "Unique conversions", :visitors),
|
top_stats_entry(results, comparison, "Unique conversions", :visitors, graphable?: true),
|
||||||
top_stats_entry(results, comparison, "Total conversions", :events),
|
top_stats_entry(results, comparison, "Total conversions", :events, graphable?: true),
|
||||||
on_full_build do
|
on_full_build do
|
||||||
top_stats_entry(results, comparison, "Average revenue", :average_revenue, &format_money/1)
|
top_stats_entry(results, comparison, "Average revenue", :average_revenue,
|
||||||
|
formatter: &format_money/1,
|
||||||
|
graphable?: true
|
||||||
|
)
|
||||||
end,
|
end,
|
||||||
on_full_build do
|
on_full_build do
|
||||||
top_stats_entry(results, comparison, "Total revenue", :total_revenue, &format_money/1)
|
top_stats_entry(results, comparison, "Total revenue", :total_revenue,
|
||||||
|
formatter: &format_money/1,
|
||||||
|
graphable?: true
|
||||||
|
)
|
||||||
end,
|
end,
|
||||||
top_stats_entry(results, comparison, "Conversion rate", :conversion_rate)
|
top_stats_entry(results, comparison, "Conversion rate", :conversion_rate, graphable?: true)
|
||||||
]
|
]
|
||||||
|> Enum.reject(&is_nil/1)
|
|> Enum.reject(&is_nil/1)
|
||||||
|> then(&{&1, 100})
|
|> then(&{&1, 100})
|
||||||
@ -382,39 +393,63 @@ defmodule PlausibleWeb.Api.StatsController do
|
|||||||
|
|
||||||
stats =
|
stats =
|
||||||
[
|
[
|
||||||
top_stats_entry(current_results, prev_results, "Unique visitors", :visitors),
|
top_stats_entry(current_results, prev_results, "Unique visitors", :visitors,
|
||||||
top_stats_entry(current_results, prev_results, "Total visits", :visits),
|
graphable?: true
|
||||||
top_stats_entry(current_results, prev_results, "Total pageviews", :pageviews),
|
),
|
||||||
top_stats_entry(current_results, prev_results, "Views per visit", :views_per_visit),
|
top_stats_entry(current_results, prev_results, "Total visits", :visits, graphable?: true),
|
||||||
top_stats_entry(current_results, prev_results, "Bounce rate", :bounce_rate),
|
top_stats_entry(current_results, prev_results, "Total pageviews", :pageviews,
|
||||||
top_stats_entry(current_results, prev_results, "Visit duration", :visit_duration),
|
graphable?: true
|
||||||
top_stats_entry(current_results, prev_results, "Time on page", :time_on_page, fn
|
),
|
||||||
nil -> 0
|
top_stats_entry(current_results, prev_results, "Views per visit", :views_per_visit,
|
||||||
value -> value
|
graphable?: true
|
||||||
end)
|
),
|
||||||
|
top_stats_entry(current_results, prev_results, "Bounce rate", :bounce_rate,
|
||||||
|
graphable?: true
|
||||||
|
),
|
||||||
|
top_stats_entry(current_results, prev_results, "Visit duration", :visit_duration,
|
||||||
|
graphable?: true
|
||||||
|
),
|
||||||
|
top_stats_entry(current_results, prev_results, "Time on page", :time_on_page,
|
||||||
|
formatter: fn
|
||||||
|
nil -> 0
|
||||||
|
value -> value
|
||||||
|
end
|
||||||
|
)
|
||||||
]
|
]
|
||||||
|> Enum.filter(& &1)
|
|> Enum.filter(& &1)
|
||||||
|
|
||||||
{stats, current_results[:sample_percent][:value]}
|
{stats, current_results[:sample_percent][:value]}
|
||||||
end
|
end
|
||||||
|
|
||||||
defp top_stats_entry(current_results, prev_results, name, key, formatter \\ & &1) do
|
defp top_stats_entry(current_results, prev_results, name, key, opts \\ []) do
|
||||||
if current_results[key] do
|
if current_results[key] do
|
||||||
|
formatter = Keyword.get(opts, :formatter, & &1)
|
||||||
value = get_in(current_results, [key, :value])
|
value = get_in(current_results, [key, :value])
|
||||||
|
|
||||||
if prev_results do
|
%{name: name, value: formatter.(value)}
|
||||||
prev_value = get_in(prev_results, [key, :value])
|
|> maybe_put_graph_metric(opts, key)
|
||||||
change = Stats.Compare.calculate_change(key, prev_value, value)
|
|> maybe_put_comparison(prev_results, key, value, formatter)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
%{
|
defp maybe_put_graph_metric(entry, opts, key) do
|
||||||
name: name,
|
if Keyword.get(opts, :graphable?) do
|
||||||
value: formatter.(value),
|
entry |> Map.put(:graph_metric, key)
|
||||||
comparison_value: formatter.(prev_value),
|
else
|
||||||
change: change
|
entry
|
||||||
}
|
end
|
||||||
else
|
end
|
||||||
%{name: name, value: formatter.(value)}
|
|
||||||
end
|
defp maybe_put_comparison(entry, prev_results, key, value, formatter) do
|
||||||
|
if prev_results do
|
||||||
|
prev_value = get_in(prev_results, [key, :value])
|
||||||
|
change = Stats.Compare.calculate_change(key, prev_value, value)
|
||||||
|
|
||||||
|
entry
|
||||||
|
|> Map.put(:comparison_value, formatter.(prev_value))
|
||||||
|
|> Map.put(:change, change)
|
||||||
|
else
|
||||||
|
entry
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -64,6 +64,7 @@ defmodule PlausibleWeb.StatsController do
|
|||||||
|> render("stats.html",
|
|> render("stats.html",
|
||||||
site: site,
|
site: site,
|
||||||
has_goals: Plausible.Sites.has_goals?(site),
|
has_goals: Plausible.Sites.has_goals?(site),
|
||||||
|
revenue_goals: list_revenue_goals(site),
|
||||||
funnels: list_funnels(site),
|
funnels: list_funnels(site),
|
||||||
has_props: Plausible.Props.configured?(site),
|
has_props: Plausible.Props.configured?(site),
|
||||||
stats_start_date: stats_start_date,
|
stats_start_date: stats_start_date,
|
||||||
@ -92,10 +93,13 @@ defmodule PlausibleWeb.StatsController do
|
|||||||
defp list_funnels(site) do
|
defp list_funnels(site) do
|
||||||
Plausible.Funnels.list(site)
|
Plausible.Funnels.list(site)
|
||||||
end
|
end
|
||||||
else
|
|
||||||
defp list_funnels(_site) do
|
defp list_revenue_goals(site) do
|
||||||
[]
|
Plausible.Goals.list_revenue_goals(site)
|
||||||
end
|
end
|
||||||
|
else
|
||||||
|
defp list_funnels(_site), do: []
|
||||||
|
defp list_revenue_goals(_site), do: []
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
@ -319,6 +323,7 @@ defmodule PlausibleWeb.StatsController do
|
|||||||
|> render("stats.html",
|
|> render("stats.html",
|
||||||
site: shared_link.site,
|
site: shared_link.site,
|
||||||
has_goals: Sites.has_goals?(shared_link.site),
|
has_goals: Sites.has_goals?(shared_link.site),
|
||||||
|
revenue_goals: list_revenue_goals(shared_link.site),
|
||||||
funnels: list_funnels(shared_link.site),
|
funnels: list_funnels(shared_link.site),
|
||||||
has_props: Plausible.Props.configured?(shared_link.site),
|
has_props: Plausible.Props.configured?(shared_link.site),
|
||||||
stats_start_date: stats_start_date,
|
stats_start_date: stats_start_date,
|
||||||
|
@ -27,6 +27,7 @@
|
|||||||
data-props-available={
|
data-props-available={
|
||||||
to_string(Plausible.Billing.Feature.Props.check_availability(@site.owner) == :ok)
|
to_string(Plausible.Billing.Feature.Props.check_availability(@site.owner) == :ok)
|
||||||
}
|
}
|
||||||
|
data-revenue-goals={Jason.encode!(@revenue_goals)}
|
||||||
data-funnels={Jason.encode!(@funnels)}
|
data-funnels={Jason.encode!(@funnels)}
|
||||||
data-has-props={to_string(@has_props)}
|
data-has-props={to_string(@has_props)}
|
||||||
data-logged-in={to_string(!!@conn.assigns[:current_user])}
|
data-logged-in={to_string(!!@conn.assigns[:current_user])}
|
||||||
|
@ -92,6 +92,24 @@ defmodule Plausible.GoalsTest do
|
|||||||
assert [currency: {"is invalid", _}] = changeset.errors
|
assert [currency: {"is invalid", _}] = changeset.errors
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@tag :full_build_only
|
||||||
|
test "list_revenue_goals/1 lists event_names and currencies for each revenue goal" do
|
||||||
|
site = insert(:site)
|
||||||
|
|
||||||
|
Goals.create(site, %{"event_name" => "One", "currency" => "EUR"})
|
||||||
|
Goals.create(site, %{"event_name" => "Two", "currency" => "EUR"})
|
||||||
|
Goals.create(site, %{"event_name" => "Three", "currency" => "USD"})
|
||||||
|
Goals.create(site, %{"event_name" => "Four"})
|
||||||
|
Goals.create(site, %{"page_path" => "/some-page"})
|
||||||
|
|
||||||
|
revenue_goals = Goals.list_revenue_goals(site)
|
||||||
|
|
||||||
|
assert length(revenue_goals) == 3
|
||||||
|
assert %{event_name: "One", currency: :EUR} in revenue_goals
|
||||||
|
assert %{event_name: "Two", currency: :EUR} in revenue_goals
|
||||||
|
assert %{event_name: "Three", currency: :USD} in revenue_goals
|
||||||
|
end
|
||||||
|
|
||||||
test "create/2 clears currency for pageview goals" do
|
test "create/2 clears currency for pageview goals" do
|
||||||
site = insert(:site)
|
site = insert(:site)
|
||||||
{:ok, goal} = Goals.create(site, %{"page_path" => "/purchase", "currency" => "EUR"})
|
{:ok, goal} = Goals.create(site, %{"page_path" => "/purchase", "currency" => "EUR"})
|
||||||
|
@ -6,6 +6,36 @@ defmodule PlausibleWeb.Api.StatsController.TopStatsTest do
|
|||||||
describe "GET /api/stats/top-stats - default" do
|
describe "GET /api/stats/top-stats - default" do
|
||||||
setup [:create_user, :log_in, :create_new_site]
|
setup [:create_user, :log_in, :create_new_site]
|
||||||
|
|
||||||
|
test "returns graph_metric key for graphable top stats", %{conn: conn, site: site} do
|
||||||
|
[visitors, visits, pageviews, views_per_visit, bounce_rate, visit_duration] =
|
||||||
|
conn
|
||||||
|
|> get("/api/stats/#{site.domain}/top-stats")
|
||||||
|
|> json_response(200)
|
||||||
|
|> Map.get("top_stats")
|
||||||
|
|
||||||
|
assert %{"graph_metric" => "visitors"} = visitors
|
||||||
|
assert %{"graph_metric" => "visits"} = visits
|
||||||
|
assert %{"graph_metric" => "pageviews"} = pageviews
|
||||||
|
assert %{"graph_metric" => "views_per_visit"} = views_per_visit
|
||||||
|
assert %{"graph_metric" => "bounce_rate"} = bounce_rate
|
||||||
|
assert %{"graph_metric" => "visit_duration"} = visit_duration
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns graph_metric key for graphable top stats in realtime mode", %{
|
||||||
|
conn: conn,
|
||||||
|
site: site
|
||||||
|
} do
|
||||||
|
[current_visitors, unique_visitors, pageviews] =
|
||||||
|
conn
|
||||||
|
|> get("/api/stats/#{site.domain}/top-stats?period=realtime")
|
||||||
|
|> json_response(200)
|
||||||
|
|> Map.get("top_stats")
|
||||||
|
|
||||||
|
refute Map.has_key?(current_visitors, "graph_metric")
|
||||||
|
assert %{"graph_metric" => "visitors"} = unique_visitors
|
||||||
|
assert %{"graph_metric" => "pageviews"} = pageviews
|
||||||
|
end
|
||||||
|
|
||||||
test "counts distinct user ids", %{conn: conn, site: site} do
|
test "counts distinct user ids", %{conn: conn, site: site} do
|
||||||
populate_stats(site, [
|
populate_stats(site, [
|
||||||
build(:pageview, user_id: @user_id),
|
build(:pageview, user_id: @user_id),
|
||||||
@ -19,7 +49,8 @@ defmodule PlausibleWeb.Api.StatsController.TopStatsTest do
|
|||||||
|
|
||||||
assert %{
|
assert %{
|
||||||
"name" => "Unique visitors",
|
"name" => "Unique visitors",
|
||||||
"value" => 2
|
"value" => 2,
|
||||||
|
"graph_metric" => "visitors"
|
||||||
} in res["top_stats"]
|
} in res["top_stats"]
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -36,7 +67,8 @@ defmodule PlausibleWeb.Api.StatsController.TopStatsTest do
|
|||||||
|
|
||||||
assert %{
|
assert %{
|
||||||
"name" => "Total pageviews",
|
"name" => "Total pageviews",
|
||||||
"value" => 3
|
"value" => 3,
|
||||||
|
"graph_metric" => "pageviews"
|
||||||
} in res["top_stats"]
|
} in res["top_stats"]
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -59,12 +91,16 @@ defmodule PlausibleWeb.Api.StatsController.TopStatsTest do
|
|||||||
res = json_response(conn, 200)
|
res = json_response(conn, 200)
|
||||||
|
|
||||||
assert res["top_stats"] == [
|
assert res["top_stats"] == [
|
||||||
%{"name" => "Unique visitors", "value" => 2},
|
%{"name" => "Unique visitors", "value" => 2, "graph_metric" => "visitors"},
|
||||||
%{"name" => "Total visits", "value" => 2},
|
%{"name" => "Total visits", "value" => 2, "graph_metric" => "visits"},
|
||||||
%{"name" => "Total pageviews", "value" => 2},
|
%{"name" => "Total pageviews", "value" => 2, "graph_metric" => "pageviews"},
|
||||||
%{"name" => "Views per visit", "value" => 2.0},
|
%{
|
||||||
%{"name" => "Bounce rate", "value" => 0},
|
"name" => "Views per visit",
|
||||||
%{"name" => "Visit duration", "value" => 120}
|
"value" => 2.0,
|
||||||
|
"graph_metric" => "views_per_visit"
|
||||||
|
},
|
||||||
|
%{"name" => "Bounce rate", "value" => 0, "graph_metric" => "bounce_rate"},
|
||||||
|
%{"name" => "Visit duration", "value" => 120, "graph_metric" => "visit_duration"}
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -80,7 +116,9 @@ defmodule PlausibleWeb.Api.StatsController.TopStatsTest do
|
|||||||
|
|
||||||
res = json_response(conn, 200)
|
res = json_response(conn, 200)
|
||||||
|
|
||||||
assert %{"name" => "Views per visit", "value" => 1.33} in res["top_stats"]
|
assert %{"name" => "Views per visit", "value" => 1.33, "graph_metric" => "views_per_visit"} in res[
|
||||||
|
"top_stats"
|
||||||
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
test "calculates bounce rate", %{conn: conn, site: site} do
|
test "calculates bounce rate", %{conn: conn, site: site} do
|
||||||
@ -94,7 +132,9 @@ defmodule PlausibleWeb.Api.StatsController.TopStatsTest do
|
|||||||
|
|
||||||
res = json_response(conn, 200)
|
res = json_response(conn, 200)
|
||||||
|
|
||||||
assert %{"name" => "Bounce rate", "value" => 50} in res["top_stats"]
|
assert %{"name" => "Bounce rate", "value" => 50, "graph_metric" => "bounce_rate"} in res[
|
||||||
|
"top_stats"
|
||||||
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
test "calculates average visit duration", %{conn: conn, site: site} do
|
test "calculates average visit duration", %{conn: conn, site: site} do
|
||||||
@ -116,7 +156,9 @@ defmodule PlausibleWeb.Api.StatsController.TopStatsTest do
|
|||||||
|
|
||||||
res = json_response(conn, 200)
|
res = json_response(conn, 200)
|
||||||
|
|
||||||
assert %{"name" => "Visit duration", "value" => 450} in res["top_stats"]
|
assert %{"name" => "Visit duration", "value" => 450, "graph_metric" => "visit_duration"} in res[
|
||||||
|
"top_stats"
|
||||||
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
test "calculates time on page instead when filtered for page", %{conn: conn, site: site} do
|
test "calculates time on page instead when filtered for page", %{conn: conn, site: site} do
|
||||||
@ -339,7 +381,7 @@ defmodule PlausibleWeb.Api.StatsController.TopStatsTest do
|
|||||||
filters = Jason.encode!(%{page: "/"})
|
filters = Jason.encode!(%{page: "/"})
|
||||||
path = "/api/stats/#{site.domain}/top-stats?period=day&date=2021-01-01&filters=#{filters}"
|
path = "/api/stats/#{site.domain}/top-stats?period=day&date=2021-01-01&filters=#{filters}"
|
||||||
|
|
||||||
assert %{"name" => "Bounce rate", "value" => 0} ==
|
assert %{"name" => "Bounce rate", "value" => 0, "graph_metric" => "bounce_rate"} ==
|
||||||
conn
|
conn
|
||||||
|> get(path)
|
|> get(path)
|
||||||
|> json_response(200)
|
|> json_response(200)
|
||||||
@ -435,9 +477,17 @@ defmodule PlausibleWeb.Api.StatsController.TopStatsTest do
|
|||||||
|
|
||||||
res = json_response(conn, 200)
|
res = json_response(conn, 200)
|
||||||
|
|
||||||
assert %{"name" => "Bounce rate", "value" => 0} in res["top_stats"]
|
assert %{"name" => "Bounce rate", "value" => 0, "graph_metric" => "bounce_rate"} in res[
|
||||||
assert %{"name" => "Views per visit", "value" => 0.0} in res["top_stats"]
|
"top_stats"
|
||||||
assert %{"name" => "Visit duration", "value" => 0} in res["top_stats"]
|
]
|
||||||
|
|
||||||
|
assert %{"name" => "Views per visit", "value" => 0.0, "graph_metric" => "views_per_visit"} in res[
|
||||||
|
"top_stats"
|
||||||
|
]
|
||||||
|
|
||||||
|
assert %{"name" => "Visit duration", "value" => 0, "graph_metric" => "visit_duration"} in res[
|
||||||
|
"top_stats"
|
||||||
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
test "merges imported data into all top stat metrics", %{
|
test "merges imported data into all top stat metrics", %{
|
||||||
@ -468,12 +518,16 @@ defmodule PlausibleWeb.Api.StatsController.TopStatsTest do
|
|||||||
res = json_response(conn, 200)
|
res = json_response(conn, 200)
|
||||||
|
|
||||||
assert res["top_stats"] == [
|
assert res["top_stats"] == [
|
||||||
%{"name" => "Unique visitors", "value" => 3},
|
%{"name" => "Unique visitors", "value" => 3, "graph_metric" => "visitors"},
|
||||||
%{"name" => "Total visits", "value" => 3},
|
%{"name" => "Total visits", "value" => 3, "graph_metric" => "visits"},
|
||||||
%{"name" => "Total pageviews", "value" => 4},
|
%{"name" => "Total pageviews", "value" => 4, "graph_metric" => "pageviews"},
|
||||||
%{"name" => "Views per visit", "value" => 1.33},
|
%{
|
||||||
%{"name" => "Bounce rate", "value" => 33},
|
"name" => "Views per visit",
|
||||||
%{"name" => "Visit duration", "value" => 303}
|
"value" => 1.33,
|
||||||
|
"graph_metric" => "views_per_visit"
|
||||||
|
},
|
||||||
|
%{"name" => "Bounce rate", "value" => 33, "graph_metric" => "bounce_rate"},
|
||||||
|
%{"name" => "Visit duration", "value" => 303, "graph_metric" => "visit_duration"}
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@ -504,7 +558,12 @@ defmodule PlausibleWeb.Api.StatsController.TopStatsTest do
|
|||||||
conn = get(conn, "/api/stats/#{site.domain}/top-stats?period=realtime")
|
conn = get(conn, "/api/stats/#{site.domain}/top-stats?period=realtime")
|
||||||
|
|
||||||
res = json_response(conn, 200)
|
res = json_response(conn, 200)
|
||||||
assert %{"name" => "Unique visitors (last 30 min)", "value" => 2} in res["top_stats"]
|
|
||||||
|
assert %{
|
||||||
|
"name" => "Unique visitors (last 30 min)",
|
||||||
|
"value" => 2,
|
||||||
|
"graph_metric" => "visitors"
|
||||||
|
} in res["top_stats"]
|
||||||
end
|
end
|
||||||
|
|
||||||
test "shows pageviews (last 30 minutes)", %{conn: conn, site: site} do
|
test "shows pageviews (last 30 minutes)", %{conn: conn, site: site} do
|
||||||
@ -518,7 +577,10 @@ defmodule PlausibleWeb.Api.StatsController.TopStatsTest do
|
|||||||
conn = get(conn, "/api/stats/#{site.domain}/top-stats?period=realtime")
|
conn = get(conn, "/api/stats/#{site.domain}/top-stats?period=realtime")
|
||||||
|
|
||||||
res = json_response(conn, 200)
|
res = json_response(conn, 200)
|
||||||
assert %{"name" => "Pageviews (last 30 min)", "value" => 3} in res["top_stats"]
|
|
||||||
|
assert %{"name" => "Pageviews (last 30 min)", "value" => 3, "graph_metric" => "pageviews"} in res[
|
||||||
|
"top_stats"
|
||||||
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
test "shows current visitors (last 5 min) with goal filter", %{conn: conn, site: site} do
|
test "shows current visitors (last 5 min) with goal filter", %{conn: conn, site: site} do
|
||||||
@ -554,14 +616,77 @@ defmodule PlausibleWeb.Api.StatsController.TopStatsTest do
|
|||||||
conn = get(conn, "/api/stats/#{site.domain}/top-stats?period=realtime&filters=#{filters}")
|
conn = get(conn, "/api/stats/#{site.domain}/top-stats?period=realtime&filters=#{filters}")
|
||||||
|
|
||||||
res = json_response(conn, 200)
|
res = json_response(conn, 200)
|
||||||
assert %{"name" => "Unique conversions (last 30 min)", "value" => 2} in res["top_stats"]
|
|
||||||
assert %{"name" => "Total conversions (last 30 min)", "value" => 4} in res["top_stats"]
|
assert %{
|
||||||
|
"name" => "Unique conversions (last 30 min)",
|
||||||
|
"value" => 2,
|
||||||
|
"graph_metric" => "visitors"
|
||||||
|
} in res["top_stats"]
|
||||||
|
|
||||||
|
assert %{
|
||||||
|
"name" => "Total conversions (last 30 min)",
|
||||||
|
"value" => 4,
|
||||||
|
"graph_metric" => "events"
|
||||||
|
} in res["top_stats"]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "GET /api/stats/top-stats - filters" do
|
describe "GET /api/stats/top-stats - filters" do
|
||||||
setup [:create_user, :log_in, :create_new_site]
|
setup [:create_user, :log_in, :create_new_site]
|
||||||
|
|
||||||
|
test "returns graph_metric key for graphable top stats with a page filter", %{
|
||||||
|
conn: conn,
|
||||||
|
site: site
|
||||||
|
} do
|
||||||
|
filters = Jason.encode!(%{page: "/A"})
|
||||||
|
|
||||||
|
[visitors, visits, pageviews, bounce_rate, time_on_page] =
|
||||||
|
conn
|
||||||
|
|> get("/api/stats/#{site.domain}/top-stats?filters=#{filters}")
|
||||||
|
|> json_response(200)
|
||||||
|
|> Map.get("top_stats")
|
||||||
|
|
||||||
|
assert %{"graph_metric" => "visitors"} = visitors
|
||||||
|
assert %{"graph_metric" => "visits"} = visits
|
||||||
|
assert %{"graph_metric" => "pageviews"} = pageviews
|
||||||
|
assert %{"graph_metric" => "bounce_rate"} = bounce_rate
|
||||||
|
|
||||||
|
refute Map.has_key?(time_on_page, "graph_metric")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns graph_metric key for graphable top stats with a goal filter", %{
|
||||||
|
conn: conn,
|
||||||
|
site: site
|
||||||
|
} do
|
||||||
|
filters = Jason.encode!(%{goal: "Signup"})
|
||||||
|
|
||||||
|
[unique_visitors, unique_conversions, total_conversions, cr] =
|
||||||
|
conn
|
||||||
|
|> get("/api/stats/#{site.domain}/top-stats?filters=#{filters}")
|
||||||
|
|> json_response(200)
|
||||||
|
|> Map.get("top_stats")
|
||||||
|
|
||||||
|
refute Map.has_key?(unique_visitors, "graph_metric")
|
||||||
|
assert %{"graph_metric" => "visitors"} = unique_conversions
|
||||||
|
assert %{"graph_metric" => "events"} = total_conversions
|
||||||
|
assert %{"graph_metric" => "conversion_rate"} = cr
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns graph_metric key for graphable top stats with a goal filter in realtime mode",
|
||||||
|
%{conn: conn, site: site} do
|
||||||
|
filters = Jason.encode!(%{goal: "Signup"})
|
||||||
|
|
||||||
|
[current_visitors, unique_conversions, total_conversions] =
|
||||||
|
conn
|
||||||
|
|> get("/api/stats/#{site.domain}/top-stats?period=realtime&filters=#{filters}")
|
||||||
|
|> json_response(200)
|
||||||
|
|> Map.get("top_stats")
|
||||||
|
|
||||||
|
refute Map.has_key?(current_visitors, "graph_metric")
|
||||||
|
assert %{"graph_metric" => "visitors"} = unique_conversions
|
||||||
|
assert %{"graph_metric" => "events"} = total_conversions
|
||||||
|
end
|
||||||
|
|
||||||
test "returns only visitors from a country based on alpha2 code", %{conn: conn, site: site} do
|
test "returns only visitors from a country based on alpha2 code", %{conn: conn, site: site} do
|
||||||
populate_stats(site, [
|
populate_stats(site, [
|
||||||
build(:pageview, country_code: "US"),
|
build(:pageview, country_code: "US"),
|
||||||
@ -579,7 +704,9 @@ defmodule PlausibleWeb.Api.StatsController.TopStatsTest do
|
|||||||
|
|
||||||
res = json_response(conn, 200)
|
res = json_response(conn, 200)
|
||||||
|
|
||||||
assert %{"name" => "Unique visitors", "value" => 2} in res["top_stats"]
|
assert %{"name" => "Unique visitors", "value" => 2, "graph_metric" => "visitors"} in res[
|
||||||
|
"top_stats"
|
||||||
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
test "page glob filter", %{conn: conn, site: site} do
|
test "page glob filter", %{conn: conn, site: site} do
|
||||||
@ -599,7 +726,9 @@ defmodule PlausibleWeb.Api.StatsController.TopStatsTest do
|
|||||||
|
|
||||||
res = json_response(conn, 200)
|
res = json_response(conn, 200)
|
||||||
|
|
||||||
assert %{"name" => "Unique visitors", "value" => 2} in res["top_stats"]
|
assert %{"name" => "Unique visitors", "value" => 2, "graph_metric" => "visitors"} in res[
|
||||||
|
"top_stats"
|
||||||
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
test "contains (~) filter", %{conn: conn, site: site} do
|
test "contains (~) filter", %{conn: conn, site: site} do
|
||||||
@ -619,7 +748,9 @@ defmodule PlausibleWeb.Api.StatsController.TopStatsTest do
|
|||||||
|
|
||||||
res = json_response(conn, 200)
|
res = json_response(conn, 200)
|
||||||
|
|
||||||
assert %{"name" => "Unique visitors", "value" => 2} in res["top_stats"]
|
assert %{"name" => "Unique visitors", "value" => 2, "graph_metric" => "visitors"} in res[
|
||||||
|
"top_stats"
|
||||||
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
test "returns only visitors with specific screen size", %{conn: conn, site: site} do
|
test "returns only visitors with specific screen size", %{conn: conn, site: site} do
|
||||||
@ -639,7 +770,9 @@ defmodule PlausibleWeb.Api.StatsController.TopStatsTest do
|
|||||||
|
|
||||||
res = json_response(conn, 200)
|
res = json_response(conn, 200)
|
||||||
|
|
||||||
assert %{"name" => "Unique visitors", "value" => 2} in res["top_stats"]
|
assert %{"name" => "Unique visitors", "value" => 2, "graph_metric" => "visitors"} in res[
|
||||||
|
"top_stats"
|
||||||
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
test "returns only visitors with specific screen size for a given hostname", %{
|
test "returns only visitors with specific screen size for a given hostname", %{
|
||||||
@ -691,7 +824,9 @@ defmodule PlausibleWeb.Api.StatsController.TopStatsTest do
|
|||||||
|
|
||||||
res = json_response(conn, 200)
|
res = json_response(conn, 200)
|
||||||
|
|
||||||
assert %{"name" => "Unique visitors", "value" => 2} in res["top_stats"]
|
assert %{"name" => "Unique visitors", "value" => 2, "graph_metric" => "visitors"} in res[
|
||||||
|
"top_stats"
|
||||||
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
test "returns only visitors with specific operating system", %{conn: conn, site: site} do
|
test "returns only visitors with specific operating system", %{conn: conn, site: site} do
|
||||||
@ -711,7 +846,9 @@ defmodule PlausibleWeb.Api.StatsController.TopStatsTest do
|
|||||||
|
|
||||||
res = json_response(conn, 200)
|
res = json_response(conn, 200)
|
||||||
|
|
||||||
assert %{"name" => "Unique visitors", "value" => 2} in res["top_stats"]
|
assert %{"name" => "Unique visitors", "value" => 2, "graph_metric" => "visitors"} in res[
|
||||||
|
"top_stats"
|
||||||
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
test "returns number of visits from one specific referral source", %{conn: conn, site: site} do
|
test "returns number of visits from one specific referral source", %{conn: conn, site: site} do
|
||||||
@ -744,7 +881,9 @@ defmodule PlausibleWeb.Api.StatsController.TopStatsTest do
|
|||||||
|
|
||||||
res = json_response(conn, 200)
|
res = json_response(conn, 200)
|
||||||
|
|
||||||
assert %{"name" => "Total visits", "value" => 2} in res["top_stats"]
|
assert %{"name" => "Total visits", "value" => 2, "graph_metric" => "visits"} in res[
|
||||||
|
"top_stats"
|
||||||
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
test "does not return the views_per_visit metric when a page filter is applied", %{
|
test "does not return the views_per_visit metric when a page filter is applied", %{
|
||||||
@ -890,7 +1029,9 @@ defmodule PlausibleWeb.Api.StatsController.TopStatsTest do
|
|||||||
|
|
||||||
res = json_response(conn, 200)
|
res = json_response(conn, 200)
|
||||||
|
|
||||||
assert %{"name" => "Unique conversions", "value" => 1} in res["top_stats"]
|
assert %{"name" => "Unique conversions", "value" => 1, "graph_metric" => "visitors"} in res[
|
||||||
|
"top_stats"
|
||||||
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
test "returns conversion rate", %{conn: conn, site: site} do
|
test "returns conversion rate", %{conn: conn, site: site} do
|
||||||
@ -911,7 +1052,9 @@ defmodule PlausibleWeb.Api.StatsController.TopStatsTest do
|
|||||||
|
|
||||||
res = json_response(conn, 200)
|
res = json_response(conn, 200)
|
||||||
|
|
||||||
assert %{"name" => "Conversion rate", "value" => 33.3} in res["top_stats"]
|
assert %{"name" => "Conversion rate", "value" => 33.3, "graph_metric" => "conversion_rate"} in res[
|
||||||
|
"top_stats"
|
||||||
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
test "returns conversion rate without change when comparison mode disallowed", %{
|
test "returns conversion rate without change when comparison mode disallowed", %{
|
||||||
@ -935,7 +1078,9 @@ defmodule PlausibleWeb.Api.StatsController.TopStatsTest do
|
|||||||
|
|
||||||
res = json_response(conn, 200)
|
res = json_response(conn, 200)
|
||||||
|
|
||||||
assert %{"name" => "Conversion rate", "value" => 33.3} in res["top_stats"]
|
assert %{"name" => "Conversion rate", "value" => 33.3, "graph_metric" => "conversion_rate"} in res[
|
||||||
|
"top_stats"
|
||||||
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
@tag :full_build_only
|
@tag :full_build_only
|
||||||
@ -972,12 +1117,14 @@ defmodule PlausibleWeb.Api.StatsController.TopStatsTest do
|
|||||||
|
|
||||||
assert %{
|
assert %{
|
||||||
"name" => "Average revenue",
|
"name" => "Average revenue",
|
||||||
"value" => %{"long" => "$1,659.50", "short" => "$1.7K"}
|
"value" => %{"long" => "$1,659.50", "short" => "$1.7K"},
|
||||||
|
"graph_metric" => "average_revenue"
|
||||||
} in top_stats
|
} in top_stats
|
||||||
|
|
||||||
assert %{
|
assert %{
|
||||||
"name" => "Total revenue",
|
"name" => "Total revenue",
|
||||||
"value" => %{"long" => "$3,319.00", "short" => "$3.3K"}
|
"value" => %{"long" => "$3,319.00", "short" => "$3.3K"},
|
||||||
|
"graph_metric" => "total_revenue"
|
||||||
} in top_stats
|
} in top_stats
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -1029,12 +1176,14 @@ defmodule PlausibleWeb.Api.StatsController.TopStatsTest do
|
|||||||
|
|
||||||
assert %{
|
assert %{
|
||||||
"name" => "Average revenue",
|
"name" => "Average revenue",
|
||||||
"value" => %{"long" => "$1,659.50", "short" => "$1.7K"}
|
"value" => %{"long" => "$1,659.50", "short" => "$1.7K"},
|
||||||
|
"graph_metric" => "average_revenue"
|
||||||
} in top_stats
|
} in top_stats
|
||||||
|
|
||||||
assert %{
|
assert %{
|
||||||
"name" => "Total revenue",
|
"name" => "Total revenue",
|
||||||
"value" => %{"long" => "$6,638.00", "short" => "$6.6K"}
|
"value" => %{"long" => "$6,638.00", "short" => "$6.6K"},
|
||||||
|
"graph_metric" => "total_revenue"
|
||||||
} in top_stats
|
} in top_stats
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -1101,7 +1250,7 @@ defmodule PlausibleWeb.Api.StatsController.TopStatsTest do
|
|||||||
describe "GET /api/stats/top-stats - with comparisons" do
|
describe "GET /api/stats/top-stats - with comparisons" do
|
||||||
setup [:create_user, :log_in, :create_new_site, :add_imported_data]
|
setup [:create_user, :log_in, :create_new_site, :add_imported_data]
|
||||||
|
|
||||||
test "defaults to previous period when comparison is not set", %{site: site, conn: conn} do
|
test "does not return comparisons by default", %{site: site, conn: conn} do
|
||||||
populate_stats(site, [
|
populate_stats(site, [
|
||||||
build(:pageview, timestamp: ~N[2020-12-31 00:00:00]),
|
build(:pageview, timestamp: ~N[2020-12-31 00:00:00]),
|
||||||
build(:pageview, timestamp: ~N[2020-12-31 00:00:00]),
|
build(:pageview, timestamp: ~N[2020-12-31 00:00:00]),
|
||||||
@ -1114,7 +1263,9 @@ defmodule PlausibleWeb.Api.StatsController.TopStatsTest do
|
|||||||
|
|
||||||
res = json_response(conn, 200)
|
res = json_response(conn, 200)
|
||||||
|
|
||||||
assert %{"name" => "Total visits", "value" => 3} in res["top_stats"]
|
assert %{"name" => "Total visits", "value" => 3, "graph_metric" => "visits"} in res[
|
||||||
|
"top_stats"
|
||||||
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
test "returns comparison data when mode is custom", %{site: site, conn: conn} do
|
test "returns comparison data when mode is custom", %{site: site, conn: conn} do
|
||||||
@ -1136,7 +1287,13 @@ defmodule PlausibleWeb.Api.StatsController.TopStatsTest do
|
|||||||
|
|
||||||
res = json_response(conn, 200)
|
res = json_response(conn, 200)
|
||||||
|
|
||||||
assert %{"change" => 0, "comparison_value" => 3, "name" => "Total visits", "value" => 3} in res[
|
assert %{
|
||||||
|
"change" => 0,
|
||||||
|
"comparison_value" => 3,
|
||||||
|
"name" => "Total visits",
|
||||||
|
"value" => 3,
|
||||||
|
"graph_metric" => "visits"
|
||||||
|
} in res[
|
||||||
"top_stats"
|
"top_stats"
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
@ -1195,7 +1352,13 @@ defmodule PlausibleWeb.Api.StatsController.TopStatsTest do
|
|||||||
|
|
||||||
assert %{"top_stats" => top_stats, "with_imported" => true} = json_response(conn, 200)
|
assert %{"top_stats" => top_stats, "with_imported" => true} = json_response(conn, 200)
|
||||||
|
|
||||||
assert %{"change" => 100, "comparison_value" => 2, "name" => "Total visits", "value" => 4} in top_stats
|
assert %{
|
||||||
|
"change" => 100,
|
||||||
|
"comparison_value" => 2,
|
||||||
|
"name" => "Total visits",
|
||||||
|
"value" => 4,
|
||||||
|
"graph_metric" => "visits"
|
||||||
|
} in top_stats
|
||||||
end
|
end
|
||||||
|
|
||||||
test "does not compare imported data when with_imported is set to false", %{
|
test "does not compare imported data when with_imported is set to false", %{
|
||||||
@ -1230,7 +1393,13 @@ defmodule PlausibleWeb.Api.StatsController.TopStatsTest do
|
|||||||
|
|
||||||
assert %{"top_stats" => top_stats, "with_imported" => false} = json_response(conn, 200)
|
assert %{"top_stats" => top_stats, "with_imported" => false} = json_response(conn, 200)
|
||||||
|
|
||||||
assert %{"change" => 100, "comparison_value" => 0, "name" => "Total visits", "value" => 4} in top_stats
|
assert %{
|
||||||
|
"change" => 100,
|
||||||
|
"comparison_value" => 0,
|
||||||
|
"name" => "Total visits",
|
||||||
|
"value" => 4,
|
||||||
|
"graph_metric" => "visits"
|
||||||
|
} in top_stats
|
||||||
end
|
end
|
||||||
|
|
||||||
test "compares conversion rates", %{conn: conn, site: site} do
|
test "compares conversion rates", %{conn: conn, site: site} do
|
||||||
@ -1258,7 +1427,8 @@ defmodule PlausibleWeb.Api.StatsController.TopStatsTest do
|
|||||||
"change" => -33.4,
|
"change" => -33.4,
|
||||||
"comparison_value" => 66.7,
|
"comparison_value" => 66.7,
|
||||||
"name" => "Conversion rate",
|
"name" => "Conversion rate",
|
||||||
"value" => 33.3
|
"value" => 33.3,
|
||||||
|
"graph_metric" => "conversion_rate"
|
||||||
} in res["top_stats"]
|
} in res["top_stats"]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
Loading…
Reference in New Issue
Block a user