diff --git a/assets/js/dashboard/api.js b/assets/js/dashboard/api.js
index ae808aba8d..636bf47e97 100644
--- a/assets/js/dashboard/api.js
+++ b/assets/js/dashboard/api.js
@@ -45,7 +45,7 @@ export function serializeQuery(query, extraQuery=[]) {
if (query.filters) { queryObj.filters = serializeFilters(query.filters) }
if (query.with_imported) { queryObj.with_imported = query.with_imported }
if (SHARED_LINK_AUTH) { queryObj.auth = SHARED_LINK_AUTH }
- if (query.comparison) { queryObj.comparison = true }
+ if (query.comparison) { queryObj.comparison = query.comparison }
Object.assign(queryObj, ...extraQuery)
return '?' + serialize(queryObj)
diff --git a/assets/js/dashboard/comparison-input.js b/assets/js/dashboard/comparison-input.js
index 2a1bb7f176..be332ae524 100644
--- a/assets/js/dashboard/comparison-input.js
+++ b/assets/js/dashboard/comparison-input.js
@@ -1,6 +1,14 @@
-import React from 'react'
+import React, { Fragment } from 'react'
import { withRouter } from "react-router-dom";
import { navigateToQuery } from './query'
+import { Menu, Transition } from '@headlessui/react'
+import { ChevronDownIcon } from '@heroicons/react/20/solid'
+import classNames from 'classnames'
+
+const COMPARISON_MODES = {
+ 'previous_period': 'Previous period',
+ 'year_over_year': 'Year over year',
+}
export const COMPARISON_DISABLED_PERIODS = ['realtime', 'all']
@@ -8,14 +16,46 @@ const ComparisonInput = function({ site, query, history }) {
if (!site.flags.comparisons) return null
if (COMPARISON_DISABLED_PERIODS.includes(query.period)) return null
- function update(event) {
- navigateToQuery(history, query, { comparison: event.target.checked })
+ function update(key) {
+ navigateToQuery(history, query, { comparison: key })
+ }
+
+ function renderItem({ label, value, isCurrentlySelected }) {
+ const labelClass = classNames("font-medium text-sm", { "font-bold disabled": isCurrentlySelected })
+
+ return (
+
-
-
+
+
+
+
)
}
diff --git a/assets/js/dashboard/query.js b/assets/js/dashboard/query.js
index 6d338cf805..c2fd3dbf28 100644
--- a/assets/js/dashboard/query.js
+++ b/assets/js/dashboard/query.js
@@ -19,7 +19,7 @@ export function parseQuery(querystring, site) {
period = '30d'
}
- let comparison = !!q.get('comparison')
+ let comparison = q.get('comparison')
if (COMPARISON_DISABLED_PERIODS.includes(period)) comparison = null
return {
diff --git a/lib/plausible/stats/comparisons.ex b/lib/plausible/stats/comparisons.ex
new file mode 100644
index 0000000000..0fd993650c
--- /dev/null
+++ b/lib/plausible/stats/comparisons.ex
@@ -0,0 +1,70 @@
+defmodule Plausible.Stats.Comparisons do
+ @moduledoc """
+ This module provides functions for comparing query periods.
+
+ It allows you to compare a given period with a previous period or with the
+ same period from the previous year. For example, you can compare this month's
+ main graph with last month or with the same month from last year.
+ """
+
+ alias Plausible.Stats
+
+ @modes ~w(previous_period year_over_year)
+ @disallowed_periods ~w(realtime all)
+
+ @type mode() :: String.t() | nil
+
+ @spec compare(
+ Plausible.Site.t(),
+ Stats.Query.t(),
+ mode(),
+ NaiveDateTime.t() | nil
+ ) :: {:ok, Stats.Query.t()} | {:error, :not_supported}
+ def compare(
+ %Plausible.Site{} = site,
+ %Stats.Query{} = source_query,
+ mode,
+ now \\ nil
+ ) do
+ if valid_mode?(source_query, mode) do
+ now = now || Timex.now(site.timezone)
+ {:ok, do_compare(source_query, mode, now)}
+ else
+ {:error, :not_supported}
+ end
+ end
+
+ defp do_compare(source_query, "year_over_year", now) do
+ start_date = Date.add(source_query.date_range.first, -365)
+ end_date = earliest(source_query.date_range.last, now) |> Date.add(-365)
+
+ range = Date.range(start_date, end_date)
+ %Stats.Query{source_query | date_range: range}
+ end
+
+ defp do_compare(source_query, "previous_period", now) do
+ last = earliest(source_query.date_range.last, now)
+ diff_in_days = Date.diff(source_query.date_range.first, last) - 1
+
+ new_first = Date.add(source_query.date_range.first, diff_in_days)
+ new_last = Date.add(last, diff_in_days)
+
+ range = Date.range(new_first, new_last)
+ %Stats.Query{source_query | date_range: range}
+ end
+
+ defp earliest(a, b) do
+ if Date.compare(a, b) in [:eq, :lt], do: a, else: b
+ end
+
+ @spec valid_mode?(Stats.Query.t(), mode()) :: boolean()
+ @doc """
+ Returns whether the source query and the selected mode support comparisons.
+
+ For example, the realtime view doesn't support comparisons. Additionally, only
+ #{inspect(@modes)} are supported.
+ """
+ def valid_mode?(%Stats.Query{period: period}, mode) do
+ mode in @modes && period not in @disallowed_periods
+ end
+end
diff --git a/lib/plausible/stats/query.ex b/lib/plausible/stats/query.ex
index 9723d54a65..64b79cbaf4 100644
--- a/lib/plausible/stats/query.ex
+++ b/lib/plausible/stats/query.ex
@@ -10,58 +10,7 @@ defmodule Plausible.Stats.Query do
require OpenTelemetry.Tracer, as: Tracer
alias Plausible.Stats.{FilterParser, Interval}
- def shift_back(%__MODULE__{period: "year"} = query, site) do
- # Querying current year to date
- {new_first, new_last} =
- if Timex.compare(Timex.now(site.timezone), query.date_range.first, :year) == 0 do
- diff =
- Timex.diff(
- Timex.beginning_of_year(Timex.now(site.timezone)),
- Timex.now(site.timezone),
- :days
- ) - 1
-
- {query.date_range.first |> Timex.shift(days: diff),
- Timex.now(site.timezone) |> Timex.to_date() |> Timex.shift(days: diff)}
- else
- diff = Timex.diff(query.date_range.first, query.date_range.last, :days) - 1
-
- {query.date_range.first |> Timex.shift(days: diff),
- query.date_range.last |> Timex.shift(days: diff)}
- end
-
- Map.put(query, :date_range, Date.range(new_first, new_last))
- end
-
- def shift_back(%__MODULE__{period: "month"} = query, site) do
- # Querying current month to date
- {new_first, new_last} =
- if Timex.compare(Timex.now(site.timezone), query.date_range.first, :month) == 0 do
- diff =
- Timex.diff(
- Timex.beginning_of_month(Timex.now(site.timezone)),
- Timex.now(site.timezone),
- :days
- ) - 1
-
- {query.date_range.first |> Timex.shift(days: diff),
- Timex.now(site.timezone) |> Timex.to_date() |> Timex.shift(days: diff)}
- else
- diff = Timex.diff(query.date_range.first, query.date_range.last, :days) - 1
-
- {query.date_range.first |> Timex.shift(days: diff),
- query.date_range.last |> Timex.shift(days: diff)}
- end
-
- Map.put(query, :date_range, Date.range(new_first, new_last))
- end
-
- def shift_back(query, _site) do
- diff = Timex.diff(query.date_range.first, query.date_range.last, :days) - 1
- new_first = query.date_range.first |> Timex.shift(days: diff)
- new_last = query.date_range.last |> Timex.shift(days: diff)
- Map.put(query, :date_range, Date.range(new_first, new_last))
- end
+ @type t :: %__MODULE__{}
def from(site, %{"period" => "realtime"} = params) do
date = today(site.timezone)
diff --git a/lib/plausible/stats/timeseries.ex b/lib/plausible/stats/timeseries.ex
index bfd93bac24..9b3cdf19e6 100644
--- a/lib/plausible/stats/timeseries.ex
+++ b/lib/plausible/stats/timeseries.ex
@@ -4,6 +4,10 @@ defmodule Plausible.Stats.Timeseries do
import Plausible.Stats.{Base, Util}
use Plausible.Stats.Fragments
+ @typep metric :: :pageviews | :visitors | :visits | :bounce_rate | :visit_duration
+ @typep value :: nil | integer() | float()
+ @type results :: nonempty_list(%{required(:date) => Date.t(), required(metric()) => value()})
+
@event_metrics [:visitors, :pageviews]
@session_metrics [:visits, :bounce_rate, :visit_duration, :views_per_visit]
def timeseries(site, query, metrics) do
diff --git a/lib/plausible_web/controllers/api/external_stats_controller.ex b/lib/plausible_web/controllers/api/external_stats_controller.ex
index 8f462e1530..8e01aabc6c 100644
--- a/lib/plausible_web/controllers/api/external_stats_controller.ex
+++ b/lib/plausible_web/controllers/api/external_stats_controller.ex
@@ -20,7 +20,7 @@ defmodule PlausibleWeb.Api.ExternalStatsController do
{:ok, metrics} <- parse_and_validate_metrics(params, nil, query) do
results =
if params["compare"] == "previous_period" do
- prev_query = Query.shift_back(query, site)
+ {:ok, prev_query} = Plausible.Stats.Comparisons.compare(site, query, "previous_period")
[prev_result, curr_result] =
Plausible.ClickhouseRepo.parallel_tasks([
diff --git a/lib/plausible_web/controllers/api/stats_controller.ex b/lib/plausible_web/controllers/api/stats_controller.ex
index b2d2b96ce9..499f4f7627 100644
--- a/lib/plausible_web/controllers/api/stats_controller.ex
+++ b/lib/plausible_web/controllers/api/stats_controller.ex
@@ -3,7 +3,7 @@ defmodule PlausibleWeb.Api.StatsController do
use Plausible.Repo
use Plug.ErrorHandler
alias Plausible.Stats
- alias Plausible.Stats.{Query, Filters}
+ alias Plausible.Stats.{Query, Filters, Comparisons}
require Logger
@@ -115,9 +115,9 @@ defmodule PlausibleWeb.Api.StatsController do
full_intervals = build_full_intervals(query, labels)
comparison_result =
- if params["comparison"] do
- comparison_query = Query.shift_back(query, site)
- Stats.timeseries(site, comparison_query, [selected_metric])
+ case Comparisons.compare(site, query, params["comparison"]) do
+ {:ok, comparison_query} -> Stats.timeseries(site, comparison_query, [selected_metric])
+ {:error, :not_supported} -> nil
end
json(conn, %{
@@ -174,9 +174,10 @@ defmodule PlausibleWeb.Api.StatsController do
site = conn.assigns[:site]
with :ok <- validate_params(params) do
+ comparison_mode = params["comparison"] || "previous_period"
query = Query.from(site, params) |> Filters.add_prefix()
- {top_stats, sample_percent} = fetch_top_stats(site, query)
+ {top_stats, sample_percent} = fetch_top_stats(site, query, comparison_mode)
json(conn, %{
top_stats: top_stats,
@@ -243,7 +244,8 @@ defmodule PlausibleWeb.Api.StatsController do
defp fetch_top_stats(
site,
- %Query{period: "realtime", filters: %{"event:goal" => _goal}} = query
+ %Query{period: "realtime", filters: %{"event:goal" => _goal}} = query,
+ _comparison_mode
) do
query_30m = %Query{query | period: "30m"}
@@ -270,7 +272,7 @@ defmodule PlausibleWeb.Api.StatsController do
{stats, 100}
end
- defp fetch_top_stats(site, %Query{period: "realtime"} = query) do
+ defp fetch_top_stats(site, %Query{period: "realtime"} = query, _comparison_mode) do
query_30m = %Query{query | period: "30m"}
%{
@@ -296,29 +298,41 @@ defmodule PlausibleWeb.Api.StatsController do
{stats, 100}
end
- defp fetch_top_stats(site, %Query{filters: %{"event:goal" => _goal}} = query) do
+ defp fetch_top_stats(site, %Query{filters: %{"event:goal" => _goal}} = query, comparison_mode) do
total_q = Query.remove_event_filters(query, [:goal, :props])
- prev_query = Query.shift_back(query, site)
- prev_total_query = Query.shift_back(total_q, site)
+
+ {prev_converted_visitors, prev_completions} =
+ case Stats.Comparisons.compare(site, query, comparison_mode) do
+ {:ok, prev_query} ->
+ %{visitors: %{value: prev_converted_visitors}, events: %{value: prev_completions}} =
+ Stats.aggregate(site, prev_query, [:visitors, :events])
+
+ {prev_converted_visitors, prev_completions}
+
+ {:error, :not_supported} ->
+ {nil, nil}
+ end
+
+ prev_unique_visitors =
+ case Stats.Comparisons.compare(site, total_q, comparison_mode) do
+ {:ok, prev_total_query} ->
+ site
+ |> Stats.aggregate(prev_total_query, [:visitors])
+ |> get_in([:visitors, :value])
+
+ {:error, :not_supported} ->
+ nil
+ end
%{
visitors: %{value: unique_visitors}
} = Stats.aggregate(site, total_q, [:visitors])
- %{
- visitors: %{value: prev_unique_visitors}
- } = Stats.aggregate(site, prev_total_query, [:visitors])
-
%{
visitors: %{value: converted_visitors},
events: %{value: completions}
} = Stats.aggregate(site, query, [:visitors, :events])
- %{
- visitors: %{value: prev_converted_visitors},
- events: %{value: prev_completions}
- } = Stats.aggregate(site, prev_query, [:visitors, :events])
-
conversion_rate = calculate_cr(unique_visitors, converted_visitors)
prev_conversion_rate = calculate_cr(prev_unique_visitors, prev_converted_visitors)
@@ -348,9 +362,7 @@ defmodule PlausibleWeb.Api.StatsController do
{stats, 100}
end
- defp fetch_top_stats(site, query) do
- prev_query = Query.shift_back(query, site)
-
+ defp fetch_top_stats(site, query, comparison_mode) do
metrics =
if query.filters["event:page"] do
[
@@ -375,7 +387,12 @@ defmodule PlausibleWeb.Api.StatsController do
end
current_results = Stats.aggregate(site, query, metrics)
- prev_results = Stats.aggregate(site, prev_query, metrics)
+
+ prev_results =
+ case Stats.Comparisons.compare(site, query, comparison_mode) do
+ {:ok, prev_results_query} -> Stats.aggregate(site, prev_results_query, metrics)
+ {:error, :not_supported} -> nil
+ end
stats =
[
@@ -394,11 +411,11 @@ defmodule PlausibleWeb.Api.StatsController do
defp top_stats_entry(current_results, prev_results, name, key) do
if current_results[key] do
- %{
- name: name,
- value: current_results[key][:value],
- change: calculate_change(key, prev_results[key][:value], current_results[key][:value])
- }
+ value = get_in(current_results, [key, :value])
+ prev_value = get_in(prev_results, [key, :value])
+ change = prev_value && calculate_change(key, prev_value, value)
+
+ %{name: name, value: value, change: change}
end
end
diff --git a/lib/workers/send_email_report.ex b/lib/workers/send_email_report.ex
index 2edd232241..c834f85da3 100644
--- a/lib/workers/send_email_report.ex
+++ b/lib/workers/send_email_report.ex
@@ -49,7 +49,7 @@ defmodule Plausible.Workers.SendEmailReport do
end
defp send_report(email, site, name, unsubscribe_link, query) do
- prev_query = Query.shift_back(query, site)
+ {:ok, prev_query} = Stats.Comparisons.compare(site, query, "previous_period")
curr_period = Stats.aggregate(site, query, [:pageviews, :visitors, :bounce_rate])
prev_period = Stats.aggregate(site, prev_query, [:pageviews, :visitors, :bounce_rate])
diff --git a/test/plausible/stats/comparisons_test.exs b/test/plausible/stats/comparisons_test.exs
new file mode 100644
index 0000000000..e4e28e9b36
--- /dev/null
+++ b/test/plausible/stats/comparisons_test.exs
@@ -0,0 +1,131 @@
+defmodule Plausible.Stats.ComparisonsTest do
+ use Plausible.DataCase
+ alias Plausible.Stats.{Query, Comparisons}
+
+ describe "this month" do
+ test "shifts back this month period" do
+ site = build(:site)
+ query = Query.from(site, %{"period" => "month", "date" => "2023-03-02"})
+ now = ~N[2023-03-02 14:00:00]
+
+ {:ok, comparison} = Comparisons.compare(site, query, "previous_period", now)
+
+ assert comparison.date_range.first == ~D[2023-02-27]
+ assert comparison.date_range.last == ~D[2023-02-28]
+ end
+
+ test "shifts back this month period when it's the first day of the month" do
+ site = build(:site)
+ query = Query.from(site, %{"period" => "month", "date" => "2023-03-01"})
+ now = ~N[2023-03-01 14:00:00]
+
+ {:ok, comparison} = Comparisons.compare(site, query, "previous_period", now)
+
+ assert comparison.date_range.first == ~D[2023-02-28]
+ assert comparison.date_range.last == ~D[2023-02-28]
+ end
+ end
+
+ describe "previous month" do
+ test "shifts back using the same number of days when previous_period" do
+ site = build(:site)
+ query = Query.from(site, %{"period" => "month", "date" => "2023-02-01"})
+ now = ~N[2023-03-01 14:00:00]
+
+ {:ok, comparison} = Comparisons.compare(site, query, "previous_period", now)
+
+ assert comparison.date_range.first == ~D[2023-01-04]
+ assert comparison.date_range.last == ~D[2023-01-31]
+ end
+
+ test "shifts back the full month when year_over_year" do
+ site = build(:site)
+ query = Query.from(site, %{"period" => "month", "date" => "2023-02-01"})
+ now = ~N[2023-03-01 14:00:00]
+
+ {:ok, comparison} = Comparisons.compare(site, query, "year_over_year", now)
+
+ assert comparison.date_range.first == ~D[2022-02-01]
+ assert comparison.date_range.last == ~D[2022-02-28]
+ end
+
+ test "shifts back whole month plus one day when year_over_year and a leap year" do
+ site = build(:site)
+ query = Query.from(site, %{"period" => "month", "date" => "2020-02-01"})
+ now = ~N[2023-03-01 14:00:00]
+
+ {:ok, comparison} = Comparisons.compare(site, query, "year_over_year", now)
+
+ assert comparison.date_range.first == ~D[2019-02-01]
+ assert comparison.date_range.last == ~D[2019-03-01]
+ end
+ end
+
+ describe "year to date" do
+ test "shifts back by the same number of days when previous_period" do
+ site = build(:site)
+ query = Query.from(site, %{"period" => "year", "date" => "2023-03-01"})
+ now = ~N[2023-03-01 14:00:00]
+
+ {:ok, comparison} = Comparisons.compare(site, query, "previous_period", now)
+
+ assert comparison.date_range.first == ~D[2022-11-02]
+ assert comparison.date_range.last == ~D[2022-12-31]
+ end
+
+ test "shifts back by the same number of days when year_over_year" do
+ site = build(:site)
+ query = Query.from(site, %{"period" => "year", "date" => "2023-03-01"})
+ now = ~N[2023-03-01 14:00:00]
+
+ {:ok, comparison} = Comparisons.compare(site, query, "year_over_year", now)
+
+ assert comparison.date_range.first == ~D[2022-01-01]
+ assert comparison.date_range.last == ~D[2022-03-01]
+ end
+ end
+
+ describe "previous year" do
+ test "shifts back a whole year when year_over_year" do
+ site = build(:site)
+ query = Query.from(site, %{"period" => "year", "date" => "2022-03-02"})
+
+ {:ok, comparison} = Comparisons.compare(site, query, "year_over_year")
+
+ assert comparison.date_range.first == ~D[2021-01-01]
+ assert comparison.date_range.last == ~D[2021-12-31]
+ end
+
+ test "shifts back a whole year when previous_period" do
+ site = build(:site)
+ query = Query.from(site, %{"period" => "year", "date" => "2022-03-02"})
+
+ {:ok, comparison} = Comparisons.compare(site, query, "previous_period")
+
+ assert comparison.date_range.first == ~D[2021-01-01]
+ assert comparison.date_range.last == ~D[2021-12-31]
+ end
+ end
+
+ describe "custom" do
+ test "shifts back by the same number of days when previous_period" do
+ site = build(:site)
+ query = Query.from(site, %{"period" => "custom", "date" => "2023-01-01,2023-01-07"})
+
+ {:ok, comparison} = Comparisons.compare(site, query, "previous_period")
+
+ assert comparison.date_range.first == ~D[2022-12-25]
+ assert comparison.date_range.last == ~D[2022-12-31]
+ end
+
+ test "shifts back to last year when year_over_year" do
+ site = build(:site)
+ query = Query.from(site, %{"period" => "custom", "date" => "2023-01-01,2023-01-07"})
+
+ {:ok, comparison} = Comparisons.compare(site, query, "year_over_year")
+
+ assert comparison.date_range.first == ~D[2022-01-01]
+ assert comparison.date_range.last == ~D[2022-01-07]
+ end
+ end
+end
diff --git a/test/plausible_web/controllers/api/stats_controller/main_graph_test.exs b/test/plausible_web/controllers/api/stats_controller/main_graph_test.exs
index 766f82c99b..73f60fbade 100644
--- a/test/plausible_web/controllers/api/stats_controller/main_graph_test.exs
+++ b/test/plausible_web/controllers/api/stats_controller/main_graph_test.exs
@@ -591,4 +591,65 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do
}
end
end
+
+ describe "GET /api/stats/main-graph - comparisons" do
+ setup [:create_user, :log_in, :create_new_site, :add_imported_data]
+
+ test "returns past month stats when period=30d and comparison=previous_period", %{
+ conn: conn,
+ site: site
+ } do
+ conn =
+ get(conn, "/api/stats/#{site.domain}/main-graph?period=30d&comparison=previous_period")
+
+ assert %{"labels" => labels, "comparison_labels" => comparison_labels} =
+ json_response(conn, 200)
+
+ {:ok, first} = Timex.today() |> Timex.shift(days: -30) |> Timex.format("{ISOdate}")
+ {:ok, last} = Timex.today() |> Timex.format("{ISOdate}")
+
+ assert List.first(labels) == first
+ assert List.last(labels) == last
+
+ {:ok, first} = Timex.today() |> Timex.shift(days: -61) |> Timex.format("{ISOdate}")
+ {:ok, last} = Timex.today() |> Timex.shift(days: -31) |> Timex.format("{ISOdate}")
+
+ assert List.first(comparison_labels) == first
+ assert List.last(comparison_labels) == last
+ end
+
+ test "returns past year stats when period=month and comparison=year_over_year", %{
+ 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]),
+ build(:pageview, timestamp: ~N[2020-01-31 00:00:00]),
+ build(:pageview, timestamp: ~N[2019-01-01 00:00:00]),
+ build(:pageview, timestamp: ~N[2019-01-01 00:00:00]),
+ build(:pageview, timestamp: ~N[2019-01-05 00:00:00]),
+ build(:pageview, timestamp: ~N[2019-01-05 00:00:00]),
+ build(:pageview, timestamp: ~N[2019-01-31 00:00:00])
+ ])
+
+ conn =
+ get(
+ conn,
+ "/api/stats/#{site.domain}/main-graph?period=month&date=2020-01-01&comparison=year_over_year"
+ )
+
+ assert %{"plot" => plot, "comparison_plot" => comparison_plot} = json_response(conn, 200)
+
+ assert 1 == Enum.at(plot, 0)
+ assert 2 == Enum.at(comparison_plot, 0)
+
+ assert 1 == Enum.at(plot, 4)
+ assert 2 == Enum.at(comparison_plot, 4)
+
+ assert 1 == Enum.at(plot, 30)
+ assert 1 == Enum.at(comparison_plot, 30)
+ end
+ end
end