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 ( + update(value)} + className="px-4 py-2 leading-tight hover:bg-gray-100 dark:text-white hover:text-gray-900 dark:hover:bg-gray-900 dark:hover:text-gray-100 flex hover:cursor-pointer"> + { label } + + ) } return ( -
- - +
+
+ + + { COMPARISON_MODES[query.comparison] || 'Compare to' } + + + + + { renderItem({ label: "Disabled", value: false, isCurrentlySelected: !query.comparison }) } + { Object.keys(COMPARISON_MODES).map((key) => renderItem({ label: COMPARISON_MODES[key], value: key, isCurrentlySelected: key == query.comparison})) } + + + +
) } 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