Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement "Year over Year" comparison mode #2704

Merged
merged 16 commits into from
Mar 7, 2023
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion assets/js/dashboard/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
52 changes: 46 additions & 6 deletions assets/js/dashboard/comparison-input.js
Original file line number Diff line number Diff line change
@@ -1,21 +1,61 @@
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']

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 (
<Menu.Item
key={value}
onClick={() => 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">
<span className={labelClass}>{ label }</span>
</Menu.Item>
)
}

return (
<div className="flex-none mx-3">
<input id="comparison-input" type="checkbox" onChange={update} checked={query.comparison} className="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" />
<label htmlFor="comparison-input" className="ml-1.5 font-medium text-xs md:text-sm text-gray-700 dark:text-white">Compare</label>
<div className="flex ml-auto pl-2">
<div className="w-20 sm:w-36 md:w-48 md:relative">
<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 truncate cursor-pointer w-full">
<span>{ COMPARISON_MODES[query.comparison] || 'Compare to' }</span>
<ChevronDownIcon className="hidden sm:inline-block h-4 w-4 md:h-5 md:w-5 text-gray-500 ml-5" />
</Menu.Button>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
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>
{ 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})) }
</Menu.Items>
</Transition>
</Menu>
</div>
</div>
)
}
Expand Down
2 changes: 1 addition & 1 deletion assets/js/dashboard/query.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export function parseQuery(querystring, site) {
period = '30d'
}

let comparison = !!q.get('comparison')
let comparison = q.get('comparison')
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Previously the comparison query property was a boolean, on or off. Now it supports multiple modes and it's a string, e.g. last_year, previous_period.

if (COMPARISON_DISABLED_PERIODS.includes(period)) comparison = null

return {
Expand Down
62 changes: 62 additions & 0 deletions lib/plausible/stats/comparisons.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
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()
) :: {:ok, Stats.Query.t()} | {:error, :not_supported}
def compare(
%Plausible.Site{} = site,
%Stats.Query{} = source_query,
mode,
now \\ NaiveDateTime.utc_now()
) do
if valid_mode?(source_query, mode) do
{:ok, do_compare(site, source_query, mode, now)}
else
{:error, :not_supported}
end
end

defp do_compare(site, source_query, "previous_period", _now) do
Stats.Query.shift_back(source_query, site)
end

defp do_compare(_site, 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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

elegant 👌


range = Date.range(start_date, end_date)
%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
2 changes: 2 additions & 0 deletions lib/plausible/stats/query.ex
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ defmodule Plausible.Stats.Query do
require OpenTelemetry.Tracer, as: Tracer
alias Plausible.Stats.{FilterParser, Interval}

@type t :: %__MODULE__{}

def shift_back(%__MODULE__{period: "year"} = query, site) do
# Querying current year to date
{new_first, new_last} =
Expand Down
4 changes: 4 additions & 0 deletions lib/plausible/stats/timeseries.ex
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ defmodule Plausible.Stats.Timeseries do
import Plausible.Stats.Base
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]
def timeseries(site, query, metrics) do
Expand Down
31 changes: 18 additions & 13 deletions lib/plausible_web/controllers/api/stats_controller.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -115,8 +115,8 @@ 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)
if Comparisons.valid_mode?(query, params["comparison"]) do
{:ok, comparison_query} = Comparisons.compare(site, query, params["comparison"])
Stats.timeseries(site, comparison_query, [selected_metric])
end

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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"}

Expand All @@ -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"}

%{
Expand All @@ -296,10 +298,10 @@ 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)
{:ok, prev_query} = Stats.Comparisons.compare(site, query, comparison_mode)
{:ok, prev_total_query} = Stats.Comparisons.compare(site, total_q, comparison_mode)

%{
visitors: %{value: unique_visitors}
Expand Down Expand Up @@ -348,9 +350,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
[:visitors, :pageviews, :bounce_rate, :time_on_page, :visits, :sample_percent]
Expand All @@ -359,7 +359,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 =
[
Expand Down
52 changes: 52 additions & 0 deletions test/plausible/stats/comparisons_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
defmodule Plausible.Stats.ComparisonsTest do
use Plausible.DataCase
alias Plausible.Stats.{Query, Comparisons}

describe "previous_period" do
test "shifts back this month period" 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-27]
assert comparison.date_range.last == ~D[2023-02-28]
end
end

describe "year over year" 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, "year_over_year", now)

assert comparison.date_range.first == ~D[2022-03-01]
assert comparison.date_range.last == ~D[2022-03-02]
end

test "shifts back last month period" do
site = build(:site)
query = Query.from(site, %{"period" => "month", "date" => "2023-02-02"})
now = ~N[2023-03-02 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 this year period" do
site = build(:site)
query = Query.from(site, %{"period" => "year", "date" => "2023-03-01"})
now = ~N[2023-03-02 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-02]
end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -586,4 +586,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