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:
RobertJoonas 2024-04-04 13:39:55 +01:00 committed by GitHub
parent 3115c6e7a8
commit e5b56dbe62
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 958 additions and 708 deletions

View File

@ -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,

View File

@ -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 []

View File

@ -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>
</> </>

View 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)

View 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
}
}

View 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>
)
}

View File

@ -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'}>&uarr;</span> {formattedComparison}%</span> return <span className={defaultClassName}><span className={color + ' font-bold'}>&uarr;</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'}>&darr;</span> {formattedComparison}%</span> return <span className={defaultClassName}><span className={color + ' font-bold'}>&darr;</span> {formattedComparison}%</span>
} else if (comparison === 0) { } else if (comparison === 0) {
return <span className={noChangeClassName}>&#12336; 0%</span> return <span className={noChangeClassName}>&#12336; 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;
} }

View File

@ -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() {

View 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
}
}

View File

@ -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)

View File

@ -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

View File

@ -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,

View File

@ -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])}

View File

@ -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"})

View File

@ -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