Custom comparison mode (#2753)

This commit implements the custom comparison mode using a date range.
This commit is contained in:
Vini Brasil 2023-03-22 09:31:44 -03:00 committed by GitHub
parent 9f71bb3921
commit 87a63fe28a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 272 additions and 120 deletions

View File

@ -45,7 +45,13 @@ export function serializeQuery(query, extraQuery=[]) {
if (query.filters) { queryObj.filters = serializeFilters(query.filters) } if (query.filters) { queryObj.filters = serializeFilters(query.filters) }
if (query.with_imported) { queryObj.with_imported = query.with_imported } if (query.with_imported) { queryObj.with_imported = query.with_imported }
if (SHARED_LINK_AUTH) { queryObj.auth = SHARED_LINK_AUTH } if (SHARED_LINK_AUTH) { queryObj.auth = SHARED_LINK_AUTH }
if (query.comparison) { queryObj.comparison = query.comparison }
if (query.comparison) {
queryObj.comparison = query.comparison
queryObj.compare_from = query.compare_from
queryObj.compare_to = query.compare_to
}
Object.assign(queryObj, ...extraQuery) Object.assign(queryObj, ...extraQuery)
return '?' + serialize(queryObj) return '?' + serialize(queryObj)

View File

@ -1,15 +1,18 @@
import React, { Fragment } from 'react' import React, { Fragment } from 'react'
import { withRouter } from "react-router-dom"; import { withRouter } from 'react-router-dom'
import { navigateToQuery } from './query' import { navigateToQuery } from './query'
import { Menu, Transition } from '@headlessui/react' import { Menu, Transition } from '@headlessui/react'
import { ChevronDownIcon } from '@heroicons/react/20/solid' import { ChevronDownIcon } from '@heroicons/react/20/solid'
import classNames from 'classnames' import classNames from 'classnames'
import * as storage from './util/storage' import * as storage from './util/storage'
import Flatpickr from 'react-flatpickr'
import { formatISO, parseUTCDate, formatDayShort } from './util/date.js'
const COMPARISON_MODES = { const COMPARISON_MODES = {
'off': 'Disable comparison', 'off': 'Disable comparison',
'previous_period': 'Previous period', 'previous_period': 'Previous period',
'year_over_year': 'Year over year', 'year_over_year': 'Year over year',
'custom': 'Custom period',
} }
const DEFAULT_COMPARISON_MODE = 'previous_period' const DEFAULT_COMPARISON_MODE = 'previous_period'
@ -26,6 +29,7 @@ export const getStoredComparisonMode = function(domain) {
} }
const storeComparisonMode = function(domain, mode) { const storeComparisonMode = function(domain, mode) {
if (mode == "custom") return
storage.setItem(`comparison_mode__${domain}`, mode) storage.setItem(`comparison_mode__${domain}`, mode)
} }
@ -49,17 +53,31 @@ export const toggleComparisons = function(history, query, site) {
} }
} }
function DropdownItem({ label, value, isCurrentlySelected, updateMode }) { function DropdownItem({ label, value, isCurrentlySelected, updateMode, calendar }) {
const click = () => {
if (value == "custom") {
// https://github.com/flatpickr/flatpickr/issues/399#issuecomment-260007013
// FIXME: Use setState to prevent this issue
setTimeout(() => calendar.current.flatpickr.open(), 100)
} else {
updateMode(value)
}
}
const render = ({ active }) => {
const buttonClass = classNames("px-4 py-2 w-full text-left font-medium text-sm dark:text-white cursor-pointer", {
"bg-gray-100 text-gray-900 dark:bg-gray-900 dark:text-gray-100": active,
"font-bold": isCurrentlySelected,
})
return <button className={buttonClass}>{ label }</button>
}
const disabled = isCurrentlySelected && value !== "custom"
return ( return (
<Menu.Item <Menu.Item key={value} onClick={click} disabled={disabled}>
key={value} { render }
onClick={() => updateMode(value)}
disabled={isCurrentlySelected}>
{({ active }) => (
<button className={classNames("px-4 py-2 w-full text-left font-medium text-sm dark:text-white cursor-pointer", { "bg-gray-100 text-gray-900 dark:bg-gray-900 dark:text-gray-100": active, "font-bold": isCurrentlySelected })}>
{ label }
</button>
)}
</Menu.Item> </Menu.Item>
) )
} }
@ -69,19 +87,46 @@ const ComparisonInput = function({ site, query, history }) {
if (COMPARISON_DISABLED_PERIODS.includes(query.period)) return null if (COMPARISON_DISABLED_PERIODS.includes(query.period)) return null
if (!isComparisonEnabled(query.comparison)) return null if (!isComparisonEnabled(query.comparison)) return null
const updateMode = (key) => { const updateMode = (mode, from = null, to = null) => {
storeComparisonMode(site.domain, key) storeComparisonMode(site.domain, mode)
navigateToQuery(history, query, { comparison: key }) navigateToQuery(history, query, { comparison: mode, compare_from: from, compare_to: to })
}
const buildLabel = (query) => {
if (query.comparison == "custom") {
const from = parseUTCDate(query.compare_from)
const to = parseUTCDate(query.compare_to)
return `${formatDayShort(from, false)} - ${formatDayShort(to, false)}`
} else {
return COMPARISON_MODES[query.comparison]
}
}
const calendar = React.useRef(null)
const flatpickrOptions = {
mode: 'range',
showMonths: 1,
maxDate: 'today',
minDate: parseUTCDate(site.statsBegin),
animate: true,
static: true,
onChange: ([from, to]) => {
if (from && to) updateMode("custom", formatISO(from), formatISO(to))
}
} }
return ( return (
<> <>
<span className="h-0 w-0 invisible">
<Flatpickr ref={calendar} options={flatpickrOptions} />
</span>
<span className="pl-2 text-sm font-medium text-gray-800 dark:text-gray-200">vs.</span> <span className="pl-2 text-sm font-medium text-gray-800 dark:text-gray-200">vs.</span>
<div className="flex"> <div className="flex">
<div className="min-w-32 md:w-52 md:relative"> <div className="min-w-32 md:w-48 md:relative">
<Menu as="div" className="relative inline-block pl-2 w-full"> <Menu as="div" className="relative inline-block pl-2 w-full">
<Menu.Button className="bg-white text-gray-800 text-xs md:text-sm font-medium dark:bg-gray-800 dark:hover:bg-gray-900 dark:text-gray-200 hover:bg-gray-200 flex md:px-3 px-2 py-2 items-center justify-between leading-tight rounded shadow cursor-pointer w-full truncate"> <Menu.Button className="bg-white text-gray-800 text-xs md:text-sm font-medium dark:bg-gray-800 dark:hover:bg-gray-900 dark:text-gray-200 hover:bg-gray-200 flex md:px-3 px-2 py-2 items-center justify-between leading-tight rounded shadow cursor-pointer w-full truncate">
<span className="truncate">{ COMPARISON_MODES[query.comparison] || 'Compare to' }</span> <span className="truncate">{ buildLabel(query) }</span>
<ChevronDownIcon className="hidden sm:inline-block h-4 w-4 md:h-5 md:w-5 text-gray-500 ml-2" aria-hidden="true" /> <ChevronDownIcon className="hidden sm:inline-block h-4 w-4 md:h-5 md:w-5 text-gray-500 ml-2" aria-hidden="true" />
</Menu.Button> </Menu.Button>
<Transition <Transition
@ -93,7 +138,7 @@ const ComparisonInput = function({ site, query, history }) {
leaveFrom="transform opacity-100 scale-100" leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"> leaveTo="transform 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>
{ Object.keys(COMPARISON_MODES).map((key) => DropdownItem({ label: COMPARISON_MODES[key], value: key, isCurrentlySelected: key == query.comparison, updateMode })) } { Object.keys(COMPARISON_MODES).map((key) => DropdownItem({ label: COMPARISON_MODES[key], value: key, isCurrentlySelected: key == query.comparison, updateMode, calendar })) }
</Menu.Items> </Menu.Items>
</Transition> </Transition>
</Menu> </Menu>

View File

@ -382,8 +382,6 @@ function DatePicker({query, site, history}) {
</div> </div>
); );
} if (mode === "calendar") { } if (mode === "calendar") {
const insertionDate = new Date(site.statsBegin);
const dayBeforeCreation = insertionDate - 86400000;
return ( return (
<div className="h-0"> <div className="h-0">
<Flatpickr <Flatpickr
@ -391,7 +389,7 @@ function DatePicker({query, site, history}) {
options={{ options={{
mode: 'range', mode: 'range',
maxDate: 'today', maxDate: 'today',
minDate: dayBeforeCreation, minDate: parseUTCDate(site.statsBegin),
showMonths: 1, showMonths: 1,
static: true, static: true,
animate: true}} animate: true}}

View File

@ -25,6 +25,8 @@ export function parseQuery(querystring, site) {
return { return {
period, period,
comparison, comparison,
compare_from: q.get('compare_from'),
compare_to: q.get('compare_to'),
date: q.get('date') ? parseUTCDate(q.get('date')) : nowForSite(site), date: q.get('date') ? parseUTCDate(q.get('date')) : nowForSite(site),
from: q.get('from') ? parseUTCDate(q.get('from')) : undefined, from: q.get('from') ? parseUTCDate(q.get('from')) : undefined,
to: q.get('to') ? parseUTCDate(q.get('to')) : undefined, to: q.get('to') ? parseUTCDate(q.get('to')) : undefined,

View File

@ -37,12 +37,12 @@ const buildTooltipData = function(query, graphData, metric, tooltipModel) {
const data = tooltipModel.dataPoints.find((dataPoint) => dataPoint.dataset.yAxisID == "y") const data = tooltipModel.dataPoints.find((dataPoint) => dataPoint.dataset.yAxisID == "y")
const comparisonData = tooltipModel.dataPoints.find((dataPoint) => dataPoint.dataset.yAxisID == "yComparison") const comparisonData = tooltipModel.dataPoints.find((dataPoint) => dataPoint.dataset.yAxisID == "yComparison")
const label = renderBucketLabel(query, graphData, graphData.labels[data.dataIndex]) const label = data && renderBucketLabel(query, graphData, graphData.labels[data.dataIndex])
const comparisonLabel = comparisonData && renderBucketLabel(query, graphData, graphData.comparison_labels[data.dataIndex], true) const comparisonLabel = comparisonData && renderBucketLabel(query, graphData, graphData.comparison_labels[comparisonData.dataIndex], true)
const value = data?.raw || 0 const value = data?.raw || 0
const comparisonValue = comparisonData?.raw || 0 const comparisonValue = comparisonData?.raw || 0
const comparisonDifference = comparisonData && calculatePercentageDifference(comparisonValue, value) const comparisonDifference = label && comparisonLabel && calculatePercentageDifference(comparisonValue, value)
const metricFormatter = METRIC_FORMATTER[metric] const metricFormatter = METRIC_FORMATTER[metric]
const formattedValue = metricFormatter(value) const formattedValue = metricFormatter(value)
@ -84,21 +84,23 @@ export default function GraphTooltip(graphData, metric, query) {
<aside class="text-gray-100 flex flex-col"> <aside class="text-gray-100 flex flex-col">
<div class="flex justify-between items-center"> <div class="flex justify-between items-center">
<span class="font-semibold mr-4 text-lg">${METRIC_LABELS[metric]}</span> <span class="font-semibold mr-4 text-lg">${METRIC_LABELS[metric]}</span>
<div class="inline-flex items-center space-x-1"> ${tooltipData.comparisonDifference ?
`<div class="inline-flex items-center space-x-1">
${tooltipData.comparisonDifference > 0 ? `<span class="font-semibold text-sm text-green-500">&uarr;</span><span>${tooltipData.comparisonDifference}%</span>` : ""} ${tooltipData.comparisonDifference > 0 ? `<span class="font-semibold text-sm text-green-500">&uarr;</span><span>${tooltipData.comparisonDifference}%</span>` : ""}
${tooltipData.comparisonDifference < 0 ? `<span class="font-semibold text-sm text-red-400">&darr;</span><span>${tooltipData.comparisonDifference * -1}%</span>` : ""} ${tooltipData.comparisonDifference < 0 ? `<span class="font-semibold text-sm text-red-400">&darr;</span><span>${tooltipData.comparisonDifference * -1}%</span>` : ""}
${tooltipData.comparisonDifference == 0 ? `<span class="font-semibold text-sm">〰 0%</span>` : ""} ${tooltipData.comparisonDifference == 0 ? `<span class="font-semibold text-sm">〰 0%</span>` : ""}
</div> </div>` : ''}
</div> </div>
<div class="flex flex-col"> ${tooltipData.label ?
`<div class="flex flex-col">
<div class="flex flex-row justify-between items-center"> <div class="flex flex-row justify-between items-center">
<span class="flex items-center mr-4"> <span class="flex items-center mr-4">
<div class="w-3 h-3 mr-1 rounded-full" style="background-color: rgba(101,116,205)"></div> <div class="w-3 h-3 mr-1 rounded-full" style="background-color: rgba(101,116,205)"></div>
<span>${tooltipData.label}</span> <span>${tooltipData.label}</span>
</span> </span>
<span class="text-base font-bold">${tooltipData.formattedValue}</span> <span class="text-base font-bold">${tooltipData.formattedValue}</span>
</div> </div>` : ''}
${tooltipData.comparisonLabel ? ${tooltipData.comparisonLabel ?
`<div class="flex flex-row justify-between items-center"> `<div class="flex flex-row justify-between items-center">

View File

@ -40,17 +40,11 @@ export const LoadingState = {
isLoadedOrRefreshing: function (state) { return [this.loaded, this.refreshing].includes(state) } isLoadedOrRefreshing: function (state) { return [this.loaded, this.refreshing].includes(state) }
} }
const buildComparisonDataset = function(comparisonPlot, presentIndex) { const buildComparisonDataset = function(comparisonPlot) {
if (!comparisonPlot) return [] if (!comparisonPlot) return []
let data = [...comparisonPlot]
if (presentIndex) {
const dashedPartIncludedIndex = presentIndex + 1
data = data.slice(0, dashedPartIncludedIndex)
}
return [{ return [{
data: data, data: comparisonPlot,
borderColor: 'rgba(60,70,110,0.2)', borderColor: 'rgba(60,70,110,0.2)',
pointBackgroundColor: 'rgba(60,70,110,0.2)', pointBackgroundColor: 'rgba(60,70,110,0.2)',
pointHoverBackgroundColor: 'rgba(60, 70, 110)', pointHoverBackgroundColor: 'rgba(60, 70, 110)',
@ -98,7 +92,7 @@ export const buildDataSet = (plot, comparisonPlot, present_index, ctx, label) =>
const dataset = [ const dataset = [
...buildMainPlotDataset(plot, present_index), ...buildMainPlotDataset(plot, present_index),
...buildDashedDataset(plot, present_index), ...buildDashedDataset(plot, present_index),
...buildComparisonDataset(comparisonPlot, present_index) ...buildComparisonDataset(comparisonPlot)
] ]
return dataset.map((item) => Object.assign(item, defaultOptions)) return dataset.map((item) => Object.assign(item, defaultOptions))

View File

@ -91,12 +91,12 @@ class LineGraph extends React.Component {
ticks: { ticks: {
maxTicksLimit: 8, maxTicksLimit: 8,
callback: function (val, _index, _ticks) { callback: function (val, _index, _ticks) {
// realtime graph labels are not date strings if (this.getLabelForValue(val) == "__blank__") return ""
const hasMultipleYears = typeof graphData.labels[0] !== 'string' ? false :
const hasMultipleYears =
graphData.labels graphData.labels
// date format: 'yyyy-mm-dd'; maps to -> 'yyyy' .filter((date) => typeof date === 'string')
.map(date => date.split('-')[0]) .map(date => date.split('-')[0])
// reject any year that appears at a previous index, unique years only
.filter((value, index, list) => list.indexOf(value) === index) .filter((value, index, list) => list.indexOf(value) === index)
.length > 1 .length > 1
@ -220,26 +220,12 @@ class LineGraph extends React.Component {
onClick(e) { onClick(e) {
const element = this.chart.getElementsAtEventForMode(e, 'index', { intersect: false })[0] const element = this.chart.getElementsAtEventForMode(e, 'index', { intersect: false })[0]
const date = this.chart.data.labels[element.index] const date = this.props.graphData.labels[element.index] || this.props.graphData.comparison_labels[element.index]
if (this.props.graphData.interval === 'month') { if (this.props.graphData.interval === 'month') {
navigateToQuery( navigateToQuery(this.props.history, this.props.query, { period: 'month', date })
this.props.history,
this.props.query,
{
period: 'month',
date,
}
)
} else if (this.props.graphData.interval === 'date') { } else if (this.props.graphData.interval === 'date') {
navigateToQuery( navigateToQuery(this.props.history, this.props.query, { period: 'day', date })
this.props.history,
this.props.query,
{
period: 'day',
date,
}
)
} }
} }

View File

@ -40,6 +40,10 @@ export function formatYear(date) {
return `Year of ${date.getFullYear()}`; return `Year of ${date.getFullYear()}`;
} }
export function formatYearShort(date) {
return date.getUTCFullYear().toString().substring(2)
}
export function formatDay(date) { export function formatDay(date) {
var weekday = DAYS_ABBREV[date.getDay()]; var weekday = DAYS_ABBREV[date.getDay()];
if (date.getFullYear() !== (new Date()).getFullYear()) { if (date.getFullYear() !== (new Date()).getFullYear()) {
@ -49,8 +53,13 @@ export function formatDay(date) {
} }
} }
export function formatDayShort(date) { export function formatDayShort(date, includeYear = false) {
return `${date.getDate()} ${formatMonthShort(date)}`; let formatted = `${date.getDate()} ${formatMonthShort(date)}`
if (includeYear) {
formatted += ` ${formatYearShort(date)}`
}
return formatted
} }
export function parseUTCDate(dateString) { export function parseUTCDate(dateString) {

View File

@ -9,40 +9,64 @@ defmodule Plausible.Stats.Comparisons do
alias Plausible.Stats alias Plausible.Stats
@modes ~w(previous_period year_over_year) @modes ~w(previous_period year_over_year custom)
@disallowed_periods ~w(realtime all) @disallowed_periods ~w(realtime all)
@type mode() :: String.t() | nil @type mode() :: String.t() | nil
@typep option() :: {:from, String.t()} | {:to, String.t()} | {:now, NaiveDateTime.t()}
@spec compare( @spec compare(Plausible.Site.t(), Stats.Query.t(), mode(), [option()]) ::
Plausible.Site.t(), {:ok, Stats.Query.t()} | {:error, :not_supported} | {:error, :invalid_dates}
Stats.Query.t(), @doc """
mode(), Generates a comparison query based on the source query and comparison mode.
NaiveDateTime.t() | nil
) :: {:ok, Stats.Query.t()} | {:error, :not_supported} The mode parameter specifies the type of comparison and can be one of the
def compare( following:
%Plausible.Site{} = site,
%Stats.Query{} = source_query, * `"previous_period"` - shifts back the query by the same number of days the
mode, source query has.
now \\ nil
) do * `"year_over_year"` - shifts back the query by 1 year.
* `"custom"` - compares the query using a custom date range. See options for
more details.
The comparison query returned by the function has its end date restricted to
the current day. This can be overriden by the `now` option, described below.
## Options
* `:now` - a `NaiveDateTime` struct with the current date and time. This is
optional and used for testing purposes.
* `:from` - a ISO-8601 date string used when mode is `"custom"`.
* `:to` - a ISO-8601 date string used when mode is `"custom"`. Must be
after `from`.
"""
def compare(%Plausible.Site{} = site, %Stats.Query{} = source_query, mode, opts \\ []) do
if valid_mode?(source_query, mode) do if valid_mode?(source_query, mode) do
now = now || Timex.now(site.timezone) opts = Keyword.put_new(opts, :now, Timex.now(site.timezone))
{:ok, do_compare(source_query, mode, now)} do_compare(source_query, mode, opts)
else else
{:error, :not_supported} {:error, :not_supported}
end end
end end
defp do_compare(source_query, "year_over_year", now) do defp do_compare(source_query, "year_over_year", opts) do
now = Keyword.fetch!(opts, :now)
start_date = Date.add(source_query.date_range.first, -365) start_date = Date.add(source_query.date_range.first, -365)
end_date = earliest(source_query.date_range.last, now) |> Date.add(-365) end_date = earliest(source_query.date_range.last, now) |> Date.add(-365)
range = Date.range(start_date, end_date) range = Date.range(start_date, end_date)
%Stats.Query{source_query | date_range: range} {:ok, %Stats.Query{source_query | date_range: range}}
end end
defp do_compare(source_query, "previous_period", now) do defp do_compare(source_query, "previous_period", opts) do
now = Keyword.fetch!(opts, :now)
last = earliest(source_query.date_range.last, now) last = earliest(source_query.date_range.last, now)
diff_in_days = Date.diff(source_query.date_range.first, last) - 1 diff_in_days = Date.diff(source_query.date_range.first, last) - 1
@ -50,7 +74,17 @@ defmodule Plausible.Stats.Comparisons do
new_last = Date.add(last, diff_in_days) new_last = Date.add(last, diff_in_days)
range = Date.range(new_first, new_last) range = Date.range(new_first, new_last)
%Stats.Query{source_query | date_range: range} {:ok, %Stats.Query{source_query | date_range: range}}
end
defp do_compare(source_query, "custom", opts) do
with {:ok, from} <- opts |> Keyword.fetch!(:from) |> Date.from_iso8601(),
{:ok, to} <- opts |> Keyword.fetch!(:to) |> Date.from_iso8601(),
result when result in [:eq, :lt] <- Date.compare(from, to) do
{:ok, %Stats.Query{source_query | date_range: Date.range(from, to)}}
else
_error -> {:error, :invalid_dates}
end
end end
defp earliest(a, b) do defp earliest(a, b) do

View File

@ -110,21 +110,25 @@ defmodule PlausibleWeb.Api.StatsController do
end end
timeseries_result = Stats.timeseries(site, timeseries_query, [selected_metric]) timeseries_result = Stats.timeseries(site, timeseries_query, [selected_metric])
labels = label_timeseries(timeseries_result)
present_index = present_index_for(site, query, labels)
full_intervals = build_full_intervals(query, labels)
comparison_result = comparison_result =
case Comparisons.compare(site, query, params["comparison"]) do case Comparisons.compare(site, query, params["comparison"],
from: params["compare_from"],
to: params["compare_to"]
) do
{:ok, comparison_query} -> Stats.timeseries(site, comparison_query, [selected_metric]) {:ok, comparison_query} -> Stats.timeseries(site, comparison_query, [selected_metric])
{:error, :not_supported} -> nil {:error, :not_supported} -> nil
end end
labels = label_timeseries(timeseries_result, comparison_result)
present_index = present_index_for(site, query, labels)
full_intervals = build_full_intervals(query, labels)
json(conn, %{ json(conn, %{
plot: plot_timeseries(timeseries_result, selected_metric), plot: plot_timeseries(timeseries_result, selected_metric),
labels: labels, labels: labels,
comparison_plot: comparison_result && plot_timeseries(comparison_result, selected_metric), comparison_plot: comparison_result && plot_timeseries(comparison_result, selected_metric),
comparison_labels: comparison_result && label_timeseries(comparison_result), comparison_labels: comparison_result && label_timeseries(comparison_result, nil),
present_index: present_index, present_index: present_index,
interval: query.interval, interval: query.interval,
with_imported: query.include_imported, with_imported: query.include_imported,
@ -140,8 +144,20 @@ defmodule PlausibleWeb.Api.StatsController do
Enum.map(timeseries, fn row -> row[metric] || 0 end) Enum.map(timeseries, fn row -> row[metric] || 0 end)
end end
defp label_timeseries(timeseries) do defp label_timeseries(main_result, nil) do
Enum.map(timeseries, & &1.date) Enum.map(main_result, & &1.date)
end
@blank_value "__blank__"
defp label_timeseries(main_result, comparison_result) do
blanks_to_fill = Enum.count(comparison_result) - Enum.count(main_result)
if blanks_to_fill > 0 do
blanks = List.duplicate(@blank_value, blanks_to_fill)
Enum.map(main_result, & &1.date) ++ blanks
else
Enum.map(main_result, & &1.date)
end
end end
defp build_full_intervals(%{interval: "week", date_range: range}, labels) do defp build_full_intervals(%{interval: "week", date_range: range}, labels) do
@ -175,9 +191,11 @@ defmodule PlausibleWeb.Api.StatsController do
with :ok <- validate_params(params) do with :ok <- validate_params(params) do
comparison_mode = params["comparison"] || "previous_period" comparison_mode = params["comparison"] || "previous_period"
comparison_opts = [from: params["compare_from"], to: params["compare_to"]]
query = Query.from(site, params) |> Filters.add_prefix() query = Query.from(site, params) |> Filters.add_prefix()
{top_stats, sample_percent} = fetch_top_stats(site, query, comparison_mode) {top_stats, sample_percent} = fetch_top_stats(site, query, comparison_mode, comparison_opts)
json(conn, %{ json(conn, %{
top_stats: top_stats, top_stats: top_stats,
@ -245,7 +263,8 @@ defmodule PlausibleWeb.Api.StatsController do
defp fetch_top_stats( defp fetch_top_stats(
site, site,
%Query{period: "realtime", filters: %{"event:goal" => _goal}} = query, %Query{period: "realtime", filters: %{"event:goal" => _goal}} = query,
_comparison_mode _comparison_mode,
_comparison_opts
) do ) do
query_30m = %Query{query | period: "30m"} query_30m = %Query{query | period: "30m"}
@ -272,7 +291,12 @@ defmodule PlausibleWeb.Api.StatsController do
{stats, 100} {stats, 100}
end end
defp fetch_top_stats(site, %Query{period: "realtime"} = query, _comparison_mode) do defp fetch_top_stats(
site,
%Query{period: "realtime"} = query,
_comparison_mode,
_comparison_opts
) do
query_30m = %Query{query | period: "30m"} query_30m = %Query{query | period: "30m"}
%{ %{
@ -298,11 +322,16 @@ defmodule PlausibleWeb.Api.StatsController do
{stats, 100} {stats, 100}
end end
defp fetch_top_stats(site, %Query{filters: %{"event:goal" => _goal}} = query, comparison_mode) do defp fetch_top_stats(
site,
%Query{filters: %{"event:goal" => _goal}} = query,
comparison_mode,
comparison_opts
) do
total_q = Query.remove_event_filters(query, [:goal, :props]) total_q = Query.remove_event_filters(query, [:goal, :props])
{prev_converted_visitors, prev_completions} = {prev_converted_visitors, prev_completions} =
case Stats.Comparisons.compare(site, query, comparison_mode) do case Stats.Comparisons.compare(site, query, comparison_mode, comparison_opts) do
{:ok, prev_query} -> {:ok, prev_query} ->
%{visitors: %{value: prev_converted_visitors}, events: %{value: prev_completions}} = %{visitors: %{value: prev_converted_visitors}, events: %{value: prev_completions}} =
Stats.aggregate(site, prev_query, [:visitors, :events]) Stats.aggregate(site, prev_query, [:visitors, :events])
@ -362,7 +391,7 @@ defmodule PlausibleWeb.Api.StatsController do
{stats, 100} {stats, 100}
end end
defp fetch_top_stats(site, query, comparison_mode) do defp fetch_top_stats(site, query, comparison_mode, comparison_opts) do
metrics = metrics =
if query.filters["event:page"] do if query.filters["event:page"] do
[ [
@ -389,7 +418,7 @@ defmodule PlausibleWeb.Api.StatsController do
current_results = Stats.aggregate(site, query, metrics) current_results = Stats.aggregate(site, query, metrics)
prev_results = prev_results =
case Stats.Comparisons.compare(site, query, comparison_mode) do case Stats.Comparisons.compare(site, query, comparison_mode, comparison_opts) do
{:ok, prev_results_query} -> Stats.aggregate(site, prev_results_query, metrics) {:ok, prev_results_query} -> Stats.aggregate(site, prev_results_query, metrics)
{:error, :not_supported} -> nil {:error, :not_supported} -> nil
end end

View File

@ -2,91 +2,91 @@ defmodule Plausible.Stats.ComparisonsTest do
use Plausible.DataCase use Plausible.DataCase
alias Plausible.Stats.{Query, Comparisons} alias Plausible.Stats.{Query, Comparisons}
describe "this month" do describe "with period set to this month" do
test "shifts back this month period" do test "shifts back this month period when mode is previous_period" do
site = build(:site) site = build(:site)
query = Query.from(site, %{"period" => "month", "date" => "2023-03-02"}) query = Query.from(site, %{"period" => "month", "date" => "2023-03-02"})
now = ~N[2023-03-02 14:00:00] now = ~N[2023-03-02 14:00:00]
{:ok, comparison} = Comparisons.compare(site, query, "previous_period", now) {:ok, comparison} = Comparisons.compare(site, query, "previous_period", now: now)
assert comparison.date_range.first == ~D[2023-02-27] assert comparison.date_range.first == ~D[2023-02-27]
assert comparison.date_range.last == ~D[2023-02-28] assert comparison.date_range.last == ~D[2023-02-28]
end end
test "shifts back this month period when it's the first day of the month" do test "shifts back this month period when it's the first day of the month and mode is previous_period" do
site = build(:site) site = build(:site)
query = Query.from(site, %{"period" => "month", "date" => "2023-03-01"}) query = Query.from(site, %{"period" => "month", "date" => "2023-03-01"})
now = ~N[2023-03-01 14:00:00] now = ~N[2023-03-01 14:00:00]
{:ok, comparison} = Comparisons.compare(site, query, "previous_period", now) {:ok, comparison} = Comparisons.compare(site, query, "previous_period", now: now)
assert comparison.date_range.first == ~D[2023-02-28] assert comparison.date_range.first == ~D[2023-02-28]
assert comparison.date_range.last == ~D[2023-02-28] assert comparison.date_range.last == ~D[2023-02-28]
end end
end end
describe "previous month" do describe "with period set to previous month" do
test "shifts back using the same number of days when previous_period" do test "shifts back using the same number of days when mode is previous_period" do
site = build(:site) site = build(:site)
query = Query.from(site, %{"period" => "month", "date" => "2023-02-01"}) query = Query.from(site, %{"period" => "month", "date" => "2023-02-01"})
now = ~N[2023-03-01 14:00:00] now = ~N[2023-03-01 14:00:00]
{:ok, comparison} = Comparisons.compare(site, query, "previous_period", now) {:ok, comparison} = Comparisons.compare(site, query, "previous_period", now: now)
assert comparison.date_range.first == ~D[2023-01-04] assert comparison.date_range.first == ~D[2023-01-04]
assert comparison.date_range.last == ~D[2023-01-31] assert comparison.date_range.last == ~D[2023-01-31]
end end
test "shifts back the full month when year_over_year" do test "shifts back the full month when mode is year_over_year" do
site = build(:site) site = build(:site)
query = Query.from(site, %{"period" => "month", "date" => "2023-02-01"}) query = Query.from(site, %{"period" => "month", "date" => "2023-02-01"})
now = ~N[2023-03-01 14:00:00] now = ~N[2023-03-01 14:00:00]
{:ok, comparison} = Comparisons.compare(site, query, "year_over_year", now) {:ok, comparison} = Comparisons.compare(site, query, "year_over_year", now: now)
assert comparison.date_range.first == ~D[2022-02-01] assert comparison.date_range.first == ~D[2022-02-01]
assert comparison.date_range.last == ~D[2022-02-28] assert comparison.date_range.last == ~D[2022-02-28]
end end
test "shifts back whole month plus one day when year_over_year and a leap year" do test "shifts back whole month plus one day when mode is year_over_year and a leap year" do
site = build(:site) site = build(:site)
query = Query.from(site, %{"period" => "month", "date" => "2020-02-01"}) query = Query.from(site, %{"period" => "month", "date" => "2020-02-01"})
now = ~N[2023-03-01 14:00:00] now = ~N[2023-03-01 14:00:00]
{:ok, comparison} = Comparisons.compare(site, query, "year_over_year", now) {:ok, comparison} = Comparisons.compare(site, query, "year_over_year", now: now)
assert comparison.date_range.first == ~D[2019-02-01] assert comparison.date_range.first == ~D[2019-02-01]
assert comparison.date_range.last == ~D[2019-03-01] assert comparison.date_range.last == ~D[2019-03-01]
end end
end end
describe "year to date" do describe "with period set to year to date" do
test "shifts back by the same number of days when previous_period" do test "shifts back by the same number of days when mode is previous_period" do
site = build(:site) site = build(:site)
query = Query.from(site, %{"period" => "year", "date" => "2023-03-01"}) query = Query.from(site, %{"period" => "year", "date" => "2023-03-01"})
now = ~N[2023-03-01 14:00:00] now = ~N[2023-03-01 14:00:00]
{:ok, comparison} = Comparisons.compare(site, query, "previous_period", now) {:ok, comparison} = Comparisons.compare(site, query, "previous_period", now: now)
assert comparison.date_range.first == ~D[2022-11-02] assert comparison.date_range.first == ~D[2022-11-02]
assert comparison.date_range.last == ~D[2022-12-31] assert comparison.date_range.last == ~D[2022-12-31]
end end
test "shifts back by the same number of days when year_over_year" do test "shifts back by the same number of days when mode is year_over_year" do
site = build(:site) site = build(:site)
query = Query.from(site, %{"period" => "year", "date" => "2023-03-01"}) query = Query.from(site, %{"period" => "year", "date" => "2023-03-01"})
now = ~N[2023-03-01 14:00:00] now = ~N[2023-03-01 14:00:00]
{:ok, comparison} = Comparisons.compare(site, query, "year_over_year", now) {:ok, comparison} = Comparisons.compare(site, query, "year_over_year", now: now)
assert comparison.date_range.first == ~D[2022-01-01] assert comparison.date_range.first == ~D[2022-01-01]
assert comparison.date_range.last == ~D[2022-03-01] assert comparison.date_range.last == ~D[2022-03-01]
end end
end end
describe "previous year" do describe "with period set to previous year" do
test "shifts back a whole year when year_over_year" do test "shifts back a whole year when mode is year_over_year" do
site = build(:site) site = build(:site)
query = Query.from(site, %{"period" => "year", "date" => "2022-03-02"}) query = Query.from(site, %{"period" => "year", "date" => "2022-03-02"})
@ -96,7 +96,7 @@ defmodule Plausible.Stats.ComparisonsTest do
assert comparison.date_range.last == ~D[2021-12-31] assert comparison.date_range.last == ~D[2021-12-31]
end end
test "shifts back a whole year when previous_period" do test "shifts back a whole year when mode is previous_period" do
site = build(:site) site = build(:site)
query = Query.from(site, %{"period" => "year", "date" => "2022-03-02"}) query = Query.from(site, %{"period" => "year", "date" => "2022-03-02"})
@ -107,8 +107,8 @@ defmodule Plausible.Stats.ComparisonsTest do
end end
end end
describe "custom" do describe "with period set to custom" do
test "shifts back by the same number of days when previous_period" do test "shifts back by the same number of days when mode is previous_period" do
site = build(:site) site = build(:site)
query = Query.from(site, %{"period" => "custom", "date" => "2023-01-01,2023-01-07"}) query = Query.from(site, %{"period" => "custom", "date" => "2023-01-01,2023-01-07"})
@ -118,7 +118,7 @@ defmodule Plausible.Stats.ComparisonsTest do
assert comparison.date_range.last == ~D[2022-12-31] assert comparison.date_range.last == ~D[2022-12-31]
end end
test "shifts back to last year when year_over_year" do test "shifts back to last year when mode is year_over_year" do
site = build(:site) site = build(:site)
query = Query.from(site, %{"period" => "custom", "date" => "2023-01-01,2023-01-07"}) query = Query.from(site, %{"period" => "custom", "date" => "2023-01-01,2023-01-07"})
@ -128,4 +128,28 @@ defmodule Plausible.Stats.ComparisonsTest do
assert comparison.date_range.last == ~D[2022-01-07] assert comparison.date_range.last == ~D[2022-01-07]
end end
end end
describe "with mode set to custom" do
test "sets first and last dates" do
site = build(:site)
query = Query.from(site, %{"period" => "custom", "date" => "2023-01-01,2023-01-07"})
{:ok, comparison} =
Comparisons.compare(site, query, "custom", from: "2022-05-25", to: "2022-05-30")
assert comparison.date_range.first == ~D[2022-05-25]
assert comparison.date_range.last == ~D[2022-05-30]
end
test "validates from and to dates" do
site = build(:site)
query = Query.from(site, %{"period" => "custom", "date" => "2023-01-01,2023-01-07"})
assert {:error, :invalid_dates} ==
Comparisons.compare(site, query, "custom", from: "2022-05-41", to: "2022-05-30")
assert {:error, :invalid_dates} ==
Comparisons.compare(site, query, "custom", from: "2022-05-30", to: "2022-05-25")
end
end
end end

View File

@ -806,5 +806,28 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do
assert 1 == Enum.at(plot, 30) assert 1 == Enum.at(plot, 30)
assert 1 == Enum.at(comparison_plot, 30) assert 1 == Enum.at(comparison_plot, 30)
end end
test "fill in gaps when custom comparison period is larger than original query", %{
conn: conn,
site: site
} do
populate_stats(site, [
build(:pageview, timestamp: ~N[2020-01-01 00:00:00]),
build(:pageview, timestamp: ~N[2020-01-05 00:00:00]),
build(:pageview, timestamp: ~N[2020-01-30 00:00:00])
])
conn =
get(
conn,
"/api/stats/#{site.domain}/main-graph?period=month&date=2020-01-01&comparison=custom&compare_from=2022-01-01&compare_to=2022-06-01"
)
assert %{"labels" => labels, "comparison_plot" => comparison_labels} =
json_response(conn, 200)
assert length(labels) == length(comparison_labels)
assert "__blank__" == List.last(labels)
end
end end
end end