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',
|
||||
funnelsOptedOut: container.dataset.funnelsOptedOut === 'true',
|
||||
propsOptedOut: container.dataset.propsOptedOut === 'true',
|
||||
revenueGoals: JSON.parse(container.dataset.revenueGoals),
|
||||
funnels: JSON.parse(container.dataset.funnels),
|
||||
statsBegin: container.dataset.statsBegin,
|
||||
nativeStatsBegin: container.dataset.nativeStatsBegin,
|
||||
|
@ -1,19 +1,41 @@
|
||||
import numberFormatter, {durationFormatter} from '../../util/number-formatter'
|
||||
import { parsePrefix } from '../../util/filters'
|
||||
|
||||
export const METRIC_MAPPING = {
|
||||
'Unique visitors (last 30 min)': 'visitors',
|
||||
'Pageviews (last 30 min)': 'pageviews',
|
||||
'Unique visitors': 'visitors',
|
||||
'Visit duration': 'visit_duration',
|
||||
'Total pageviews': 'pageviews',
|
||||
'Views per visit': 'views_per_visit',
|
||||
'Total visits': 'visits',
|
||||
'Bounce rate': 'bounce_rate',
|
||||
'Unique conversions': 'conversions',
|
||||
'Total conversions': 'events',
|
||||
'Conversion rate': 'conversion_rate',
|
||||
'Average revenue': 'average_revenue',
|
||||
'Total revenue': 'total_revenue',
|
||||
export function getGraphableMetrics(query, site) {
|
||||
const isRealtime = query.period === 'realtime'
|
||||
const goalFilter = query.filters.goal
|
||||
const pageFilter = query.filters.page
|
||||
|
||||
if (isRealtime && goalFilter) {
|
||||
return ["visitors"]
|
||||
} else if (isRealtime) {
|
||||
return ["visitors", "pageviews"]
|
||||
} else if (goalFilter && canGraphRevenueMetrics(goalFilter, site)) {
|
||||
return ["visitors", "events", "average_revenue", "total_revenue", "conversion_rate"]
|
||||
} else if (goalFilter) {
|
||||
return ["visitors", "events", "conversion_rate"]
|
||||
} 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 = {
|
||||
@ -44,14 +66,6 @@ export const METRIC_FORMATTER = {
|
||||
'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) {
|
||||
if (!comparisonPlot) return []
|
||||
|
||||
|
@ -6,7 +6,7 @@ import * as storage from '../../util/storage'
|
||||
import { isKeyPressed } from '../../keybinding.js'
|
||||
import { monthsBetweenDates } from '../../util/date.js'
|
||||
|
||||
export const INTERVAL_LABELS = {
|
||||
const INTERVAL_LABELS = {
|
||||
'minute': 'Minutes',
|
||||
'hour': 'Hours',
|
||||
'date': 'Days',
|
||||
@ -14,11 +14,19 @@ export const INTERVAL_LABELS = {
|
||||
'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}`)
|
||||
}
|
||||
|
||||
export const storeInterval = function(period, domain, interval) {
|
||||
function storeInterval(period, domain, interval) {
|
||||
storage.setItem(`interval__${period}__${domain}`, interval)
|
||||
}
|
||||
|
||||
@ -33,7 +41,34 @@ function subscribeKeybinding(element) {
|
||||
}, [handleKeyPress])
|
||||
}
|
||||
|
||||
function DropdownItem({ option, currentInterval, updateInterval }) {
|
||||
export const getCurrentInterval = function(site, query) {
|
||||
const options = validIntervals(site, query)
|
||||
|
||||
const storedInterval = getStoredInterval(query.period, site.domain)
|
||||
const defaultInterval = [...options].pop()
|
||||
|
||||
if (storedInterval && options.includes(storedInterval)) {
|
||||
return storedInterval
|
||||
} else {
|
||||
return defaultInterval
|
||||
}
|
||||
}
|
||||
|
||||
export function IntervalPicker({ query, site, onIntervalUpdate }) {
|
||||
if (query.period == 'realtime') return null
|
||||
|
||||
const menuElement = React.useRef(null)
|
||||
const options = validIntervals(site, query)
|
||||
const currentInterval = getCurrentInterval(site, query)
|
||||
|
||||
subscribeKeybinding(menuElement)
|
||||
|
||||
function updateInterval(interval) {
|
||||
storeInterval(query.period, site.domain, interval)
|
||||
onIntervalUpdate(interval)
|
||||
}
|
||||
|
||||
function renderDropdownItem(option) {
|
||||
return (
|
||||
<Menu.Item onClick={() => updateInterval(option)} key={option} disabled={option == currentInterval}>
|
||||
{({ active }) => (
|
||||
@ -49,23 +84,6 @@ function DropdownItem({ option, currentInterval, updateInterval }) {
|
||||
)
|
||||
}
|
||||
|
||||
export function IntervalPicker({ graphData, query, site, updateInterval }) {
|
||||
if (query.period == 'realtime') return null
|
||||
|
||||
const menuElement = React.useRef(null)
|
||||
subscribeKeybinding(menuElement)
|
||||
|
||||
let currentInterval = graphData?.interval
|
||||
|
||||
let options = site.validIntervalsByPeriod[query.period]
|
||||
if (query.period === "custom" && monthsBetweenDates(query.from, query.to) > 12) {
|
||||
options = ["week", "month"]
|
||||
}
|
||||
|
||||
if (!options.includes(currentInterval)) {
|
||||
currentInterval = [...options].pop()
|
||||
}
|
||||
|
||||
return (
|
||||
<Menu as="div" className="relative inline-block pl-2">
|
||||
{({ open }) => (
|
||||
@ -85,7 +103,7 @@ export function IntervalPicker({ graphData, query, site, updateInterval }) {
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
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>
|
||||
{options.map((option) => DropdownItem({ option, currentInterval, updateInterval }))}
|
||||
{options.map(renderDropdownItem)}
|
||||
</Menu.Items>
|
||||
</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 classNames from "classnames";
|
||||
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 { getGraphableMetrics } from "./graph-util.js";
|
||||
|
||||
function Maybe({condition, children}) {
|
||||
if (condition) {
|
||||
@ -14,8 +15,7 @@ function Maybe({condition, children}) {
|
||||
}
|
||||
}
|
||||
|
||||
export default class TopStats extends React.Component {
|
||||
renderPercentageComparison(name, comparison, forceDarkBg = false) {
|
||||
function renderPercentageComparison(name, comparison, forceDarkBg = false) {
|
||||
const formattedComparison = numberFormatter(Math.abs(comparison))
|
||||
|
||||
const defaultClassName = classNames({
|
||||
@ -41,7 +41,7 @@ export default class TopStats extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
topStatNumberShort(name, value) {
|
||||
function topStatNumberShort(name, value) {
|
||||
if (['visit duration', 'time on page'].includes(name.toLowerCase())) {
|
||||
return durationFormatter(value)
|
||||
} else if (['bounce rate', 'conversion rate'].includes(name.toLowerCase())) {
|
||||
@ -53,7 +53,7 @@ export default class TopStats extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
topStatNumberLong(name, value) {
|
||||
function topStatNumberLong(name, value) {
|
||||
if (['visit duration', 'time on page'].includes(name.toLowerCase())) {
|
||||
return durationFormatter(value)
|
||||
} else if (['bounce rate', 'conversion rate'].includes(name.toLowerCase())) {
|
||||
@ -65,48 +65,53 @@ export default class TopStats extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
topStatTooltip(stat, query) {
|
||||
export default function TopStats(props) {
|
||||
const {site, query, data, onMetricUpdate, tooltipBoundary, lastLoadTimestamp} = props
|
||||
|
||||
function tooltip(stat) {
|
||||
let statName = stat.name.toLowerCase()
|
||||
statName = stat.value === 1 ? statName.slice(0, -1) : statName
|
||||
|
||||
return (
|
||||
<div>
|
||||
{query.comparison && <div className="whitespace-nowrap">
|
||||
{this.topStatNumberLong(stat.name, stat.value)} vs. {this.topStatNumberLong(stat.name, stat.comparison_value)} {statName}
|
||||
<span className="ml-2">{this.renderPercentageComparison(stat.name, stat.change, true)}</span>
|
||||
{topStatNumberLong(stat.name, stat.value)} vs. {topStatNumberLong(stat.name, stat.comparison_value)} {statName}
|
||||
<span className="ml-2">{renderPercentageComparison(stat.name, stat.change, true)}</span>
|
||||
</div>}
|
||||
|
||||
{!query.comparison && <div className="whitespace-nowrap">
|
||||
{this.topStatNumberLong(stat.name, stat.value)} {statName}
|
||||
{topStatNumberLong(stat.name, stat.value)} {statName}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
canMetricBeGraphed(stat) {
|
||||
const isTotalUniqueVisitors = this.props.query.filters.goal && stat.name === 'Unique visitors'
|
||||
const isKnownMetric = Object.keys(METRIC_MAPPING).includes(stat.name)
|
||||
|
||||
return isKnownMetric && !isTotalUniqueVisitors
|
||||
function canMetricBeGraphed(stat) {
|
||||
const graphableMetrics = getGraphableMetrics(query, site)
|
||||
return stat.graph_metric && graphableMetrics.includes(stat.graph_metric)
|
||||
}
|
||||
|
||||
maybeUpdateMetric(stat) {
|
||||
if (this.canMetricBeGraphed(stat)) {
|
||||
this.props.updateMetric(METRIC_MAPPING[stat.name])
|
||||
function maybeUpdateMetric(stat) {
|
||||
if (canMetricBeGraphed(stat)) {
|
||||
storage.setItem(`metric__${site.domain}`, stat.graph_metric)
|
||||
onMetricUpdate(stat.graph_metric)
|
||||
}
|
||||
}
|
||||
|
||||
blinkingDot() {
|
||||
function blinkingDot() {
|
||||
return (
|
||||
<div key="dot" className="block pulsating-circle" style={{ left: '125px', top: '52px' }}></div>
|
||||
)
|
||||
}
|
||||
|
||||
renderStatName(stat) {
|
||||
const { metric } = this.props
|
||||
const isSelected = metric === METRIC_MAPPING[stat.name]
|
||||
function getStoredMetric() {
|
||||
return storage.getItem(`metric__${site.domain}`)
|
||||
}
|
||||
|
||||
function renderStatName(stat) {
|
||||
const isSelected = stat.graph_metric === getStoredMetric()
|
||||
|
||||
const [statDisplayName, statExtraName] = stat.name.split(/(\(.+\))/g)
|
||||
|
||||
@ -123,48 +128,45 @@ export default class TopStats extends React.Component {
|
||||
)
|
||||
}
|
||||
|
||||
render() {
|
||||
const { topStatData, query, site } = this.props
|
||||
|
||||
const stats = topStatData && topStatData.top_stats.map((stat, index) => {
|
||||
|
||||
function renderStat(stat, index) {
|
||||
const className = classNames('px-4 md:px-6 w-1/2 my-4 lg:w-auto group select-none', {
|
||||
'cursor-pointer': this.canMetricBeGraphed(stat),
|
||||
'cursor-pointer': 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)}
|
||||
<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={METRIC_MAPPING[stat.name]}>{this.topStatNumberShort(stat.name, stat.value)}</p>
|
||||
<p className="font-bold text-xl dark:text-gray-100" id={stat.graph_metric}>{topStatNumberShort(stat.name, stat.value)}</p>
|
||||
<Maybe condition={!query.comparison}>
|
||||
{ this.renderPercentageComparison(stat.name, stat.change) }
|
||||
{ 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>
|
||||
<p className="text-xs dark:text-gray-100">{ formatDateRange(site, data.from, data.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>
|
||||
<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>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
if (stats && query && query.period === 'realtime') {
|
||||
stats.push(this.blinkingDot())
|
||||
const stats = data && data.top_stats.map(renderStat)
|
||||
|
||||
if (stats && query.period === 'realtime') {
|
||||
stats.push(blinkingDot())
|
||||
}
|
||||
|
||||
return stats || null;
|
||||
}
|
||||
}
|
||||
|
@ -1,533 +1,154 @@
|
||||
import React from 'react';
|
||||
import { withRouter, Link } from 'react-router-dom'
|
||||
import Chart from 'chart.js/auto';
|
||||
import { navigateToQuery } from '../../query'
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import * as api from '../../api'
|
||||
import * as storage from '../../util/storage'
|
||||
import LazyLoader from '../../components/lazy-loader'
|
||||
import GraphTooltip from './graph-tooltip'
|
||||
import { buildDataSet, METRIC_MAPPING, METRIC_LABELS, METRIC_FORMATTER, LoadingState } from './graph-util'
|
||||
import dateFormatter from './date-formatter';
|
||||
import { getGraphableMetrics } from './graph-util'
|
||||
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 * as url from '../../util/url'
|
||||
import classNames from 'classnames';
|
||||
import { monthsBetweenDates, parseNaiveDate, isBefore } from '../../util/date'
|
||||
import { isComparisonEnabled } from '../../comparison-input'
|
||||
import { BarsArrowUpIcon } from '@heroicons/react/20/solid'
|
||||
import LineGraphWithRouter from './line-graph'
|
||||
|
||||
const calculateMaximumY = function(dataset) {
|
||||
const yAxisValues = dataset
|
||||
.flatMap((item) => item.data)
|
||||
.map((item) => item || 0)
|
||||
function fetchTopStats(site, query) {
|
||||
const q = { ...query }
|
||||
|
||||
if (yAxisValues) {
|
||||
return Math.max(...yAxisValues)
|
||||
} else {
|
||||
return 1
|
||||
}
|
||||
if (!isComparisonEnabled(q.comparison)) {
|
||||
q.comparison = 'previous_period'
|
||||
}
|
||||
|
||||
class LineGraph extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.boundary = React.createRef()
|
||||
this.regenerateChart = this.regenerateChart.bind(this);
|
||||
this.updateWindowDimensions = this.updateWindowDimensions.bind(this);
|
||||
this.state = {
|
||||
exported: false
|
||||
};
|
||||
return api.get(url.apiPath(site, '/top-stats'), q)
|
||||
}
|
||||
|
||||
regenerateChart() {
|
||||
const { graphData, metric, query } = this.props
|
||||
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.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 =
|
||||
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}`
|
||||
function fetchMainGraph(site, query, metric, interval) {
|
||||
const params = {metric, interval}
|
||||
return api.get(url.apiPath(site, '/main-graph'), query, params)
|
||||
}
|
||||
|
||||
if (graphData.interval === 'minute' && query.period !== 'realtime') {
|
||||
return dateFormatter({
|
||||
interval: "hour", longForm: false, period: query.period,
|
||||
})(this.getLabelForValue(val))
|
||||
export default function VisitorGraph(props) {
|
||||
const {site, query, lastLoadTimestamp} = props
|
||||
const isRealtime = query.period === 'realtime'
|
||||
const isDarkTheme = document.querySelector('html').classList.contains('dark') || false
|
||||
|
||||
const topStatsBoundary = useRef(null)
|
||||
|
||||
const [topStatData, setTopStatData] = useState(null)
|
||||
const [topStatsLoading, setTopStatsLoading] = useState(true)
|
||||
const [graphData, setGraphData] = useState(null)
|
||||
const [graphLoading, setGraphLoading] = useState(true)
|
||||
|
||||
// This state is explicitly meant for the situation where either graph interval
|
||||
// or graph metric is changed. That results in behaviour where Top Stats stay
|
||||
// intact, but the graph container alone will display a loading spinner for as
|
||||
// long as new graph data is fetched.
|
||||
const [graphRefreshing, setGraphRefreshing] = useState(false)
|
||||
|
||||
|
||||
const onIntervalUpdate = useCallback((newInterval) => {
|
||||
setGraphData(null)
|
||||
setGraphRefreshing(true)
|
||||
fetchGraphData(getStoredMetric(), newInterval)
|
||||
}, [query])
|
||||
|
||||
const onMetricUpdate = useCallback((newMetric) => {
|
||||
setGraphData(null)
|
||||
setGraphRefreshing(true)
|
||||
fetchGraphData(newMetric, getCurrentInterval(site, query))
|
||||
}, [query])
|
||||
|
||||
useEffect(() => {
|
||||
setTopStatData(null)
|
||||
setTopStatsLoading(true)
|
||||
setGraphData(null)
|
||||
setGraphLoading(true)
|
||||
fetchTopStatsAndGraphData()
|
||||
|
||||
if (isRealtime) {
|
||||
document.addEventListener('tick', fetchTopStatsAndGraphData)
|
||||
}
|
||||
|
||||
return dateFormatter({
|
||||
interval: graphData.interval, longForm: false, period: query.period, shouldShowYear: hasMultipleYears,
|
||||
})(this.getLabelForValue(val))
|
||||
},
|
||||
color: this.props.darkTheme ? 'rgb(243, 244, 246)' : undefined
|
||||
return () => {
|
||||
document.removeEventListener('tick', fetchTopStatsAndGraphData)
|
||||
}
|
||||
}
|
||||
},
|
||||
interaction: {
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
}
|
||||
}
|
||||
});
|
||||
}, [query])
|
||||
|
||||
useEffect(() => {
|
||||
if (topStatData) { storeTopStatsContainerHeight() }
|
||||
}, [topStatData])
|
||||
|
||||
function fetchTopStatsAndGraphData() {
|
||||
fetchTopStats(site, query)
|
||||
.then((res) => {
|
||||
setTopStatData(res)
|
||||
setTopStatsLoading(false)
|
||||
})
|
||||
|
||||
let metric = getStoredMetric()
|
||||
const availableMetrics = getGraphableMetrics(query, site)
|
||||
|
||||
if (!availableMetrics.includes(metric)) {
|
||||
metric = availableMetrics[0]
|
||||
storage.setItem(`metric__${site.domain}`, metric)
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
const interval = getCurrentInterval(site, query)
|
||||
|
||||
fetchGraphData(metric, interval)
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (this.props.graphData) {
|
||||
this.chart = this.regenerateChart();
|
||||
}
|
||||
window.addEventListener('mousemove', this.repositionTooltip);
|
||||
function fetchGraphData(metric, interval) {
|
||||
fetchMainGraph(site, query, metric, interval)
|
||||
.then((res) => {
|
||||
setGraphData(res)
|
||||
setGraphLoading(false)
|
||||
setGraphRefreshing(false)
|
||||
})
|
||||
}
|
||||
|
||||
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();
|
||||
function getStoredMetric() {
|
||||
return storage.getItem(`metric__${site.domain}`)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
onClick(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 })
|
||||
}
|
||||
}
|
||||
|
||||
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 isComparingImportedPeriod = isBeforeNativeStats(this.props.topStatData.comparing_from)
|
||||
|
||||
if (isQueryingImportedPeriod || isComparingImportedPeriod) {
|
||||
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 (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
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
|
||||
// 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
|
||||
// to keep track of the Top Stats container height.
|
||||
getTopStatsHeight() {
|
||||
if (this.props.topStatData) {
|
||||
function getTopStatsHeight() {
|
||||
if (topStatData) {
|
||||
return 'auto'
|
||||
} 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 (
|
||||
<div>
|
||||
<div id="top-stats-container" className="flex flex-wrap" ref={this.boundary} style={{ height: this.getTopStatsHeight() }}>
|
||||
<TopStats site={site} query={query} metric={metric} updateMetric={updateMetric} topStatData={topStatData} tooltipBoundary={this.boundary.current} lastLoadTimestamp={lastLoadTimestamp} />
|
||||
<div className={"relative w-full mt-2 bg-white rounded shadow-xl dark:bg-gray-825"}>
|
||||
{(topStatsLoading || graphLoading) && renderLoader()}
|
||||
<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 className="relative px-2">
|
||||
{mainGraphRefreshing && renderLoader()}
|
||||
{graphRefreshing && renderLoader()}
|
||||
<div className="absolute right-4 -top-8 py-1 flex items-center">
|
||||
{this.downloadLink()}
|
||||
{this.samplingNotice()}
|
||||
{this.importedNotice()}
|
||||
<IntervalPicker site={site} query={query} graphData={graphData} metric={metric} updateInterval={updateInterval} />
|
||||
{!isRealtime && <StatsExport site={site} query={query} />}
|
||||
<SamplingNotice samplePercent={topStatData}/>
|
||||
<WithImportedSwitch site={site} topStatData={topStatData} />
|
||||
<IntervalPicker site={site} query={query} onIntervalUpdate={onIntervalUpdate} />
|
||||
</div>
|
||||
<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>
|
||||
<LineGraphWithRouter graphData={graphData} darkTheme={isDarkTheme} query={query} />
|
||||
</div>
|
||||
</FadeIn>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
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() {
|
||||
return (
|
||||
|
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 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
|
||||
site
|
||||
|> for_site_query(opts)
|
||||
|
@ -129,6 +129,7 @@ defmodule PlausibleWeb.Api.StatsController do
|
||||
full_intervals = build_full_intervals(query, labels)
|
||||
|
||||
json(conn, %{
|
||||
metric: metric,
|
||||
plot: plot_timeseries(timeseries_result, metric),
|
||||
labels: labels,
|
||||
comparison_plot: comparison_result && plot_timeseries(comparison_result, metric),
|
||||
@ -294,10 +295,12 @@ defmodule PlausibleWeb.Api.StatsController do
|
||||
},
|
||||
%{
|
||||
name: "Unique conversions (last 30 min)",
|
||||
graph_metric: :visitors,
|
||||
value: unique_conversions
|
||||
},
|
||||
%{
|
||||
name: "Total conversions (last 30 min)",
|
||||
graph_metric: :events,
|
||||
value: total_conversions
|
||||
}
|
||||
]
|
||||
@ -320,10 +323,12 @@ defmodule PlausibleWeb.Api.StatsController do
|
||||
},
|
||||
%{
|
||||
name: "Unique visitors (last 30 min)",
|
||||
graph_metric: :visitors,
|
||||
value: visitors
|
||||
},
|
||||
%{
|
||||
name: "Pageviews (last 30 min)",
|
||||
graph_metric: :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 conversions", :visitors),
|
||||
top_stats_entry(results, comparison, "Total conversions", :events),
|
||||
top_stats_entry(results, comparison, "Unique conversions", :visitors, graphable?: true),
|
||||
top_stats_entry(results, comparison, "Total conversions", :events, graphable?: true),
|
||||
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,
|
||||
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,
|
||||
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)
|
||||
|> then(&{&1, 100})
|
||||
@ -382,39 +393,63 @@ defmodule PlausibleWeb.Api.StatsController do
|
||||
|
||||
stats =
|
||||
[
|
||||
top_stats_entry(current_results, prev_results, "Unique visitors", :visitors),
|
||||
top_stats_entry(current_results, prev_results, "Total visits", :visits),
|
||||
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, "Bounce rate", :bounce_rate),
|
||||
top_stats_entry(current_results, prev_results, "Visit duration", :visit_duration),
|
||||
top_stats_entry(current_results, prev_results, "Time on page", :time_on_page, fn
|
||||
top_stats_entry(current_results, prev_results, "Unique visitors", :visitors,
|
||||
graphable?: true
|
||||
),
|
||||
top_stats_entry(current_results, prev_results, "Total visits", :visits, graphable?: true),
|
||||
top_stats_entry(current_results, prev_results, "Total pageviews", :pageviews,
|
||||
graphable?: true
|
||||
),
|
||||
top_stats_entry(current_results, prev_results, "Views per visit", :views_per_visit,
|
||||
graphable?: true
|
||||
),
|
||||
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)
|
||||
end
|
||||
)
|
||||
]
|
||||
|> Enum.filter(& &1)
|
||||
|
||||
{stats, current_results[:sample_percent][:value]}
|
||||
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
|
||||
formatter = Keyword.get(opts, :formatter, & &1)
|
||||
value = get_in(current_results, [key, :value])
|
||||
|
||||
%{name: name, value: formatter.(value)}
|
||||
|> maybe_put_graph_metric(opts, key)
|
||||
|> maybe_put_comparison(prev_results, key, value, formatter)
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_put_graph_metric(entry, opts, key) do
|
||||
if Keyword.get(opts, :graphable?) do
|
||||
entry |> Map.put(:graph_metric, key)
|
||||
else
|
||||
entry
|
||||
end
|
||||
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)
|
||||
|
||||
%{
|
||||
name: name,
|
||||
value: formatter.(value),
|
||||
comparison_value: formatter.(prev_value),
|
||||
change: change
|
||||
}
|
||||
entry
|
||||
|> Map.put(:comparison_value, formatter.(prev_value))
|
||||
|> Map.put(:change, change)
|
||||
else
|
||||
%{name: name, value: formatter.(value)}
|
||||
end
|
||||
entry
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -64,6 +64,7 @@ defmodule PlausibleWeb.StatsController do
|
||||
|> render("stats.html",
|
||||
site: site,
|
||||
has_goals: Plausible.Sites.has_goals?(site),
|
||||
revenue_goals: list_revenue_goals(site),
|
||||
funnels: list_funnels(site),
|
||||
has_props: Plausible.Props.configured?(site),
|
||||
stats_start_date: stats_start_date,
|
||||
@ -92,10 +93,13 @@ defmodule PlausibleWeb.StatsController do
|
||||
defp list_funnels(site) do
|
||||
Plausible.Funnels.list(site)
|
||||
end
|
||||
else
|
||||
defp list_funnels(_site) do
|
||||
[]
|
||||
|
||||
defp list_revenue_goals(site) do
|
||||
Plausible.Goals.list_revenue_goals(site)
|
||||
end
|
||||
else
|
||||
defp list_funnels(_site), do: []
|
||||
defp list_revenue_goals(_site), do: []
|
||||
end
|
||||
|
||||
@doc """
|
||||
@ -319,6 +323,7 @@ defmodule PlausibleWeb.StatsController do
|
||||
|> render("stats.html",
|
||||
site: 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),
|
||||
has_props: Plausible.Props.configured?(shared_link.site),
|
||||
stats_start_date: stats_start_date,
|
||||
|
@ -27,6 +27,7 @@
|
||||
data-props-available={
|
||||
to_string(Plausible.Billing.Feature.Props.check_availability(@site.owner) == :ok)
|
||||
}
|
||||
data-revenue-goals={Jason.encode!(@revenue_goals)}
|
||||
data-funnels={Jason.encode!(@funnels)}
|
||||
data-has-props={to_string(@has_props)}
|
||||
data-logged-in={to_string(!!@conn.assigns[:current_user])}
|
||||
|
@ -92,6 +92,24 @@ defmodule Plausible.GoalsTest do
|
||||
assert [currency: {"is invalid", _}] = changeset.errors
|
||||
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
|
||||
site = insert(:site)
|
||||
{: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
|
||||
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
|
||||
populate_stats(site, [
|
||||
build(:pageview, user_id: @user_id),
|
||||
@ -19,7 +49,8 @@ defmodule PlausibleWeb.Api.StatsController.TopStatsTest do
|
||||
|
||||
assert %{
|
||||
"name" => "Unique visitors",
|
||||
"value" => 2
|
||||
"value" => 2,
|
||||
"graph_metric" => "visitors"
|
||||
} in res["top_stats"]
|
||||
end
|
||||
|
||||
@ -36,7 +67,8 @@ defmodule PlausibleWeb.Api.StatsController.TopStatsTest do
|
||||
|
||||
assert %{
|
||||
"name" => "Total pageviews",
|
||||
"value" => 3
|
||||
"value" => 3,
|
||||
"graph_metric" => "pageviews"
|
||||
} in res["top_stats"]
|
||||
end
|
||||
|
||||
@ -59,12 +91,16 @@ defmodule PlausibleWeb.Api.StatsController.TopStatsTest do
|
||||
res = json_response(conn, 200)
|
||||
|
||||
assert res["top_stats"] == [
|
||||
%{"name" => "Unique visitors", "value" => 2},
|
||||
%{"name" => "Total visits", "value" => 2},
|
||||
%{"name" => "Total pageviews", "value" => 2},
|
||||
%{"name" => "Views per visit", "value" => 2.0},
|
||||
%{"name" => "Bounce rate", "value" => 0},
|
||||
%{"name" => "Visit duration", "value" => 120}
|
||||
%{"name" => "Unique visitors", "value" => 2, "graph_metric" => "visitors"},
|
||||
%{"name" => "Total visits", "value" => 2, "graph_metric" => "visits"},
|
||||
%{"name" => "Total pageviews", "value" => 2, "graph_metric" => "pageviews"},
|
||||
%{
|
||||
"name" => "Views per visit",
|
||||
"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
|
||||
|
||||
@ -80,7 +116,9 @@ defmodule PlausibleWeb.Api.StatsController.TopStatsTest do
|
||||
|
||||
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
|
||||
|
||||
test "calculates bounce rate", %{conn: conn, site: site} do
|
||||
@ -94,7 +132,9 @@ defmodule PlausibleWeb.Api.StatsController.TopStatsTest do
|
||||
|
||||
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
|
||||
|
||||
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)
|
||||
|
||||
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
|
||||
|
||||
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: "/"})
|
||||
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
|
||||
|> get(path)
|
||||
|> json_response(200)
|
||||
@ -435,9 +477,17 @@ defmodule PlausibleWeb.Api.StatsController.TopStatsTest do
|
||||
|
||||
res = json_response(conn, 200)
|
||||
|
||||
assert %{"name" => "Bounce rate", "value" => 0} in res["top_stats"]
|
||||
assert %{"name" => "Views per visit", "value" => 0.0} in res["top_stats"]
|
||||
assert %{"name" => "Visit duration", "value" => 0} in res["top_stats"]
|
||||
assert %{"name" => "Bounce rate", "value" => 0, "graph_metric" => "bounce_rate"} 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
|
||||
|
||||
test "merges imported data into all top stat metrics", %{
|
||||
@ -468,12 +518,16 @@ defmodule PlausibleWeb.Api.StatsController.TopStatsTest do
|
||||
res = json_response(conn, 200)
|
||||
|
||||
assert res["top_stats"] == [
|
||||
%{"name" => "Unique visitors", "value" => 3},
|
||||
%{"name" => "Total visits", "value" => 3},
|
||||
%{"name" => "Total pageviews", "value" => 4},
|
||||
%{"name" => "Views per visit", "value" => 1.33},
|
||||
%{"name" => "Bounce rate", "value" => 33},
|
||||
%{"name" => "Visit duration", "value" => 303}
|
||||
%{"name" => "Unique visitors", "value" => 3, "graph_metric" => "visitors"},
|
||||
%{"name" => "Total visits", "value" => 3, "graph_metric" => "visits"},
|
||||
%{"name" => "Total pageviews", "value" => 4, "graph_metric" => "pageviews"},
|
||||
%{
|
||||
"name" => "Views per visit",
|
||||
"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
|
||||
@ -504,7 +558,12 @@ defmodule PlausibleWeb.Api.StatsController.TopStatsTest do
|
||||
conn = get(conn, "/api/stats/#{site.domain}/top-stats?period=realtime")
|
||||
|
||||
res = json_response(conn, 200)
|
||||
assert %{"name" => "Unique visitors (last 30 min)", "value" => 2} in res["top_stats"]
|
||||
|
||||
assert %{
|
||||
"name" => "Unique visitors (last 30 min)",
|
||||
"value" => 2,
|
||||
"graph_metric" => "visitors"
|
||||
} in res["top_stats"]
|
||||
end
|
||||
|
||||
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")
|
||||
|
||||
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
|
||||
|
||||
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}")
|
||||
|
||||
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
|
||||
|
||||
describe "GET /api/stats/top-stats - filters" do
|
||||
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
|
||||
populate_stats(site, [
|
||||
build(:pageview, country_code: "US"),
|
||||
@ -579,7 +704,9 @@ defmodule PlausibleWeb.Api.StatsController.TopStatsTest do
|
||||
|
||||
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
|
||||
|
||||
test "page glob filter", %{conn: conn, site: site} do
|
||||
@ -599,7 +726,9 @@ defmodule PlausibleWeb.Api.StatsController.TopStatsTest do
|
||||
|
||||
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
|
||||
|
||||
test "contains (~) filter", %{conn: conn, site: site} do
|
||||
@ -619,7 +748,9 @@ defmodule PlausibleWeb.Api.StatsController.TopStatsTest do
|
||||
|
||||
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
|
||||
|
||||
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)
|
||||
|
||||
assert %{"name" => "Unique visitors", "value" => 2} in res["top_stats"]
|
||||
assert %{"name" => "Unique visitors", "value" => 2, "graph_metric" => "visitors"} in res[
|
||||
"top_stats"
|
||||
]
|
||||
end
|
||||
|
||||
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)
|
||||
|
||||
assert %{"name" => "Unique visitors", "value" => 2} in res["top_stats"]
|
||||
assert %{"name" => "Unique visitors", "value" => 2, "graph_metric" => "visitors"} in res[
|
||||
"top_stats"
|
||||
]
|
||||
end
|
||||
|
||||
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)
|
||||
|
||||
assert %{"name" => "Unique visitors", "value" => 2} in res["top_stats"]
|
||||
assert %{"name" => "Unique visitors", "value" => 2, "graph_metric" => "visitors"} in res[
|
||||
"top_stats"
|
||||
]
|
||||
end
|
||||
|
||||
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)
|
||||
|
||||
assert %{"name" => "Total visits", "value" => 2} in res["top_stats"]
|
||||
assert %{"name" => "Total visits", "value" => 2, "graph_metric" => "visits"} in res[
|
||||
"top_stats"
|
||||
]
|
||||
end
|
||||
|
||||
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)
|
||||
|
||||
assert %{"name" => "Unique conversions", "value" => 1} in res["top_stats"]
|
||||
assert %{"name" => "Unique conversions", "value" => 1, "graph_metric" => "visitors"} in res[
|
||||
"top_stats"
|
||||
]
|
||||
end
|
||||
|
||||
test "returns conversion rate", %{conn: conn, site: site} do
|
||||
@ -911,7 +1052,9 @@ defmodule PlausibleWeb.Api.StatsController.TopStatsTest do
|
||||
|
||||
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
|
||||
|
||||
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)
|
||||
|
||||
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
|
||||
|
||||
@tag :full_build_only
|
||||
@ -972,12 +1117,14 @@ defmodule PlausibleWeb.Api.StatsController.TopStatsTest do
|
||||
|
||||
assert %{
|
||||
"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
|
||||
|
||||
assert %{
|
||||
"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
|
||||
end
|
||||
|
||||
@ -1029,12 +1176,14 @@ defmodule PlausibleWeb.Api.StatsController.TopStatsTest do
|
||||
|
||||
assert %{
|
||||
"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
|
||||
|
||||
assert %{
|
||||
"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
|
||||
end
|
||||
|
||||
@ -1101,7 +1250,7 @@ defmodule PlausibleWeb.Api.StatsController.TopStatsTest do
|
||||
describe "GET /api/stats/top-stats - with comparisons" do
|
||||
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, [
|
||||
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)
|
||||
|
||||
assert %{"name" => "Total visits", "value" => 3} in res["top_stats"]
|
||||
assert %{"name" => "Total visits", "value" => 3, "graph_metric" => "visits"} in res[
|
||||
"top_stats"
|
||||
]
|
||||
end
|
||||
|
||||
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)
|
||||
|
||||
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"
|
||||
]
|
||||
end
|
||||
@ -1195,7 +1352,13 @@ defmodule PlausibleWeb.Api.StatsController.TopStatsTest do
|
||||
|
||||
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
|
||||
|
||||
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 %{"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
|
||||
|
||||
test "compares conversion rates", %{conn: conn, site: site} do
|
||||
@ -1258,7 +1427,8 @@ defmodule PlausibleWeb.Api.StatsController.TopStatsTest do
|
||||
"change" => -33.4,
|
||||
"comparison_value" => 66.7,
|
||||
"name" => "Conversion rate",
|
||||
"value" => 33.3
|
||||
"value" => 33.3,
|
||||
"graph_metric" => "conversion_rate"
|
||||
} in res["top_stats"]
|
||||
end
|
||||
end
|
||||
|
Loading…
Reference in New Issue
Block a user