Skip to content

Commit 8b0f0ca

Browse files
vinibrslukutaht
andauthored
Implement "Year over Year" comparison mode (#2704)
This pull request adds support for multiple comparison modes, changes the comparison checkbox to a combobox, and implements the year over year comparison mode. The feature is still behind a feature flag. Co-authored-by: Uku Taht <[email protected]>
1 parent 874d664 commit 8b0f0ca

File tree

11 files changed

+362
-90
lines changed

11 files changed

+362
-90
lines changed

assets/js/dashboard/api.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ export function serializeQuery(query, extraQuery=[]) {
4545
if (query.filters) { queryObj.filters = serializeFilters(query.filters) }
4646
if (query.with_imported) { queryObj.with_imported = query.with_imported }
4747
if (SHARED_LINK_AUTH) { queryObj.auth = SHARED_LINK_AUTH }
48-
if (query.comparison) { queryObj.comparison = true }
48+
if (query.comparison) { queryObj.comparison = query.comparison }
4949
Object.assign(queryObj, ...extraQuery)
5050

5151
return '?' + serialize(queryObj)

assets/js/dashboard/comparison-input.js

Lines changed: 46 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,61 @@
1-
import React from 'react'
1+
import React, { Fragment } from 'react'
22
import { withRouter } from "react-router-dom";
33
import { navigateToQuery } from './query'
4+
import { Menu, Transition } from '@headlessui/react'
5+
import { ChevronDownIcon } from '@heroicons/react/20/solid'
6+
import classNames from 'classnames'
7+
8+
const COMPARISON_MODES = {
9+
'previous_period': 'Previous period',
10+
'year_over_year': 'Year over year',
11+
}
412

513
export const COMPARISON_DISABLED_PERIODS = ['realtime', 'all']
614

715
const ComparisonInput = function({ site, query, history }) {
816
if (!site.flags.comparisons) return null
917
if (COMPARISON_DISABLED_PERIODS.includes(query.period)) return null
1018

11-
function update(event) {
12-
navigateToQuery(history, query, { comparison: event.target.checked })
19+
function update(key) {
20+
navigateToQuery(history, query, { comparison: key })
21+
}
22+
23+
function renderItem({ label, value, isCurrentlySelected }) {
24+
const labelClass = classNames("font-medium text-sm", { "font-bold disabled": isCurrentlySelected })
25+
26+
return (
27+
<Menu.Item
28+
key={value}
29+
onClick={() => update(value)}
30+
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">
31+
<span className={labelClass}>{ label }</span>
32+
</Menu.Item>
33+
)
1334
}
1435

1536
return (
16-
<div className="flex-none mx-3">
17-
<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" />
18-
<label htmlFor="comparison-input" className="ml-1.5 font-medium text-xs md:text-sm text-gray-700 dark:text-white">Compare</label>
37+
<div className="flex ml-auto pl-2">
38+
<div className="w-20 sm:w-36 md:w-48 md:relative">
39+
<Menu as="div" className="relative inline-block pl-2 w-full">
40+
<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">
41+
<span>{ COMPARISON_MODES[query.comparison] || 'Compare to' }</span>
42+
<ChevronDownIcon className="hidden sm:inline-block h-4 w-4 md:h-5 md:w-5 text-gray-500 ml-5" />
43+
</Menu.Button>
44+
<Transition
45+
as={Fragment}
46+
enter="transition ease-out duration-100"
47+
enterFrom="transform opacity-0 scale-95"
48+
enterTo="transform opacity-100 scale-100"
49+
leave="transition ease-in duration-75"
50+
leaveFrom="transform opacity-100 scale-100"
51+
leaveTo="transform opacity-0 scale-95">
52+
<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>
53+
{ renderItem({ label: "Disabled", value: false, isCurrentlySelected: !query.comparison }) }
54+
{ Object.keys(COMPARISON_MODES).map((key) => renderItem({ label: COMPARISON_MODES[key], value: key, isCurrentlySelected: key == query.comparison})) }
55+
</Menu.Items>
56+
</Transition>
57+
</Menu>
58+
</div>
1959
</div>
2060
)
2161
}

assets/js/dashboard/query.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ export function parseQuery(querystring, site) {
1919
period = '30d'
2020
}
2121

22-
let comparison = !!q.get('comparison')
22+
let comparison = q.get('comparison')
2323
if (COMPARISON_DISABLED_PERIODS.includes(period)) comparison = null
2424

2525
return {

lib/plausible/stats/comparisons.ex

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
defmodule Plausible.Stats.Comparisons do
2+
@moduledoc """
3+
This module provides functions for comparing query periods.
4+
5+
It allows you to compare a given period with a previous period or with the
6+
same period from the previous year. For example, you can compare this month's
7+
main graph with last month or with the same month from last year.
8+
"""
9+
10+
alias Plausible.Stats
11+
12+
@modes ~w(previous_period year_over_year)
13+
@disallowed_periods ~w(realtime all)
14+
15+
@type mode() :: String.t() | nil
16+
17+
@spec compare(
18+
Plausible.Site.t(),
19+
Stats.Query.t(),
20+
mode(),
21+
NaiveDateTime.t() | nil
22+
) :: {:ok, Stats.Query.t()} | {:error, :not_supported}
23+
def compare(
24+
%Plausible.Site{} = site,
25+
%Stats.Query{} = source_query,
26+
mode,
27+
now \\ nil
28+
) do
29+
if valid_mode?(source_query, mode) do
30+
now = now || Timex.now(site.timezone)
31+
{:ok, do_compare(source_query, mode, now)}
32+
else
33+
{:error, :not_supported}
34+
end
35+
end
36+
37+
defp do_compare(source_query, "year_over_year", now) do
38+
start_date = Date.add(source_query.date_range.first, -365)
39+
end_date = earliest(source_query.date_range.last, now) |> Date.add(-365)
40+
41+
range = Date.range(start_date, end_date)
42+
%Stats.Query{source_query | date_range: range}
43+
end
44+
45+
defp do_compare(source_query, "previous_period", now) do
46+
last = earliest(source_query.date_range.last, now)
47+
diff_in_days = Date.diff(source_query.date_range.first, last) - 1
48+
49+
new_first = Date.add(source_query.date_range.first, diff_in_days)
50+
new_last = Date.add(last, diff_in_days)
51+
52+
range = Date.range(new_first, new_last)
53+
%Stats.Query{source_query | date_range: range}
54+
end
55+
56+
defp earliest(a, b) do
57+
if Date.compare(a, b) in [:eq, :lt], do: a, else: b
58+
end
59+
60+
@spec valid_mode?(Stats.Query.t(), mode()) :: boolean()
61+
@doc """
62+
Returns whether the source query and the selected mode support comparisons.
63+
64+
For example, the realtime view doesn't support comparisons. Additionally, only
65+
#{inspect(@modes)} are supported.
66+
"""
67+
def valid_mode?(%Stats.Query{period: period}, mode) do
68+
mode in @modes && period not in @disallowed_periods
69+
end
70+
end

lib/plausible/stats/query.ex

Lines changed: 1 addition & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -10,58 +10,7 @@ defmodule Plausible.Stats.Query do
1010
require OpenTelemetry.Tracer, as: Tracer
1111
alias Plausible.Stats.{FilterParser, Interval}
1212

13-
def shift_back(%__MODULE__{period: "year"} = query, site) do
14-
# Querying current year to date
15-
{new_first, new_last} =
16-
if Timex.compare(Timex.now(site.timezone), query.date_range.first, :year) == 0 do
17-
diff =
18-
Timex.diff(
19-
Timex.beginning_of_year(Timex.now(site.timezone)),
20-
Timex.now(site.timezone),
21-
:days
22-
) - 1
23-
24-
{query.date_range.first |> Timex.shift(days: diff),
25-
Timex.now(site.timezone) |> Timex.to_date() |> Timex.shift(days: diff)}
26-
else
27-
diff = Timex.diff(query.date_range.first, query.date_range.last, :days) - 1
28-
29-
{query.date_range.first |> Timex.shift(days: diff),
30-
query.date_range.last |> Timex.shift(days: diff)}
31-
end
32-
33-
Map.put(query, :date_range, Date.range(new_first, new_last))
34-
end
35-
36-
def shift_back(%__MODULE__{period: "month"} = query, site) do
37-
# Querying current month to date
38-
{new_first, new_last} =
39-
if Timex.compare(Timex.now(site.timezone), query.date_range.first, :month) == 0 do
40-
diff =
41-
Timex.diff(
42-
Timex.beginning_of_month(Timex.now(site.timezone)),
43-
Timex.now(site.timezone),
44-
:days
45-
) - 1
46-
47-
{query.date_range.first |> Timex.shift(days: diff),
48-
Timex.now(site.timezone) |> Timex.to_date() |> Timex.shift(days: diff)}
49-
else
50-
diff = Timex.diff(query.date_range.first, query.date_range.last, :days) - 1
51-
52-
{query.date_range.first |> Timex.shift(days: diff),
53-
query.date_range.last |> Timex.shift(days: diff)}
54-
end
55-
56-
Map.put(query, :date_range, Date.range(new_first, new_last))
57-
end
58-
59-
def shift_back(query, _site) do
60-
diff = Timex.diff(query.date_range.first, query.date_range.last, :days) - 1
61-
new_first = query.date_range.first |> Timex.shift(days: diff)
62-
new_last = query.date_range.last |> Timex.shift(days: diff)
63-
Map.put(query, :date_range, Date.range(new_first, new_last))
64-
end
13+
@type t :: %__MODULE__{}
6514

6615
def from(site, %{"period" => "realtime"} = params) do
6716
date = today(site.timezone)

lib/plausible/stats/timeseries.ex

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ defmodule Plausible.Stats.Timeseries do
44
import Plausible.Stats.{Base, Util}
55
use Plausible.Stats.Fragments
66

7+
@typep metric :: :pageviews | :visitors | :visits | :bounce_rate | :visit_duration
8+
@typep value :: nil | integer() | float()
9+
@type results :: nonempty_list(%{required(:date) => Date.t(), required(metric()) => value()})
10+
711
@event_metrics [:visitors, :pageviews]
812
@session_metrics [:visits, :bounce_rate, :visit_duration, :views_per_visit]
913
def timeseries(site, query, metrics) do

lib/plausible_web/controllers/api/external_stats_controller.ex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ defmodule PlausibleWeb.Api.ExternalStatsController do
2020
{:ok, metrics} <- parse_and_validate_metrics(params, nil, query) do
2121
results =
2222
if params["compare"] == "previous_period" do
23-
prev_query = Query.shift_back(query, site)
23+
{:ok, prev_query} = Plausible.Stats.Comparisons.compare(site, query, "previous_period")
2424

2525
[prev_result, curr_result] =
2626
Plausible.ClickhouseRepo.parallel_tasks([

lib/plausible_web/controllers/api/stats_controller.ex

Lines changed: 45 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ defmodule PlausibleWeb.Api.StatsController do
33
use Plausible.Repo
44
use Plug.ErrorHandler
55
alias Plausible.Stats
6-
alias Plausible.Stats.{Query, Filters}
6+
alias Plausible.Stats.{Query, Filters, Comparisons}
77

88
require Logger
99

@@ -115,9 +115,9 @@ defmodule PlausibleWeb.Api.StatsController do
115115
full_intervals = build_full_intervals(query, labels)
116116

117117
comparison_result =
118-
if params["comparison"] do
119-
comparison_query = Query.shift_back(query, site)
120-
Stats.timeseries(site, comparison_query, [selected_metric])
118+
case Comparisons.compare(site, query, params["comparison"]) do
119+
{:ok, comparison_query} -> Stats.timeseries(site, comparison_query, [selected_metric])
120+
{:error, :not_supported} -> nil
121121
end
122122

123123
json(conn, %{
@@ -174,9 +174,10 @@ defmodule PlausibleWeb.Api.StatsController do
174174
site = conn.assigns[:site]
175175

176176
with :ok <- validate_params(params) do
177+
comparison_mode = params["comparison"] || "previous_period"
177178
query = Query.from(site, params) |> Filters.add_prefix()
178179

179-
{top_stats, sample_percent} = fetch_top_stats(site, query)
180+
{top_stats, sample_percent} = fetch_top_stats(site, query, comparison_mode)
180181

181182
json(conn, %{
182183
top_stats: top_stats,
@@ -243,7 +244,8 @@ defmodule PlausibleWeb.Api.StatsController do
243244

244245
defp fetch_top_stats(
245246
site,
246-
%Query{period: "realtime", filters: %{"event:goal" => _goal}} = query
247+
%Query{period: "realtime", filters: %{"event:goal" => _goal}} = query,
248+
_comparison_mode
247249
) do
248250
query_30m = %Query{query | period: "30m"}
249251

@@ -270,7 +272,7 @@ defmodule PlausibleWeb.Api.StatsController do
270272
{stats, 100}
271273
end
272274

273-
defp fetch_top_stats(site, %Query{period: "realtime"} = query) do
275+
defp fetch_top_stats(site, %Query{period: "realtime"} = query, _comparison_mode) do
274276
query_30m = %Query{query | period: "30m"}
275277

276278
%{
@@ -296,29 +298,41 @@ defmodule PlausibleWeb.Api.StatsController do
296298
{stats, 100}
297299
end
298300

299-
defp fetch_top_stats(site, %Query{filters: %{"event:goal" => _goal}} = query) do
301+
defp fetch_top_stats(site, %Query{filters: %{"event:goal" => _goal}} = query, comparison_mode) do
300302
total_q = Query.remove_event_filters(query, [:goal, :props])
301-
prev_query = Query.shift_back(query, site)
302-
prev_total_query = Query.shift_back(total_q, site)
303+
304+
{prev_converted_visitors, prev_completions} =
305+
case Stats.Comparisons.compare(site, query, comparison_mode) do
306+
{:ok, prev_query} ->
307+
%{visitors: %{value: prev_converted_visitors}, events: %{value: prev_completions}} =
308+
Stats.aggregate(site, prev_query, [:visitors, :events])
309+
310+
{prev_converted_visitors, prev_completions}
311+
312+
{:error, :not_supported} ->
313+
{nil, nil}
314+
end
315+
316+
prev_unique_visitors =
317+
case Stats.Comparisons.compare(site, total_q, comparison_mode) do
318+
{:ok, prev_total_query} ->
319+
site
320+
|> Stats.aggregate(prev_total_query, [:visitors])
321+
|> get_in([:visitors, :value])
322+
323+
{:error, :not_supported} ->
324+
nil
325+
end
303326

304327
%{
305328
visitors: %{value: unique_visitors}
306329
} = Stats.aggregate(site, total_q, [:visitors])
307330

308-
%{
309-
visitors: %{value: prev_unique_visitors}
310-
} = Stats.aggregate(site, prev_total_query, [:visitors])
311-
312331
%{
313332
visitors: %{value: converted_visitors},
314333
events: %{value: completions}
315334
} = Stats.aggregate(site, query, [:visitors, :events])
316335

317-
%{
318-
visitors: %{value: prev_converted_visitors},
319-
events: %{value: prev_completions}
320-
} = Stats.aggregate(site, prev_query, [:visitors, :events])
321-
322336
conversion_rate = calculate_cr(unique_visitors, converted_visitors)
323337
prev_conversion_rate = calculate_cr(prev_unique_visitors, prev_converted_visitors)
324338

@@ -348,9 +362,7 @@ defmodule PlausibleWeb.Api.StatsController do
348362
{stats, 100}
349363
end
350364

351-
defp fetch_top_stats(site, query) do
352-
prev_query = Query.shift_back(query, site)
353-
365+
defp fetch_top_stats(site, query, comparison_mode) do
354366
metrics =
355367
if query.filters["event:page"] do
356368
[
@@ -375,7 +387,12 @@ defmodule PlausibleWeb.Api.StatsController do
375387
end
376388

377389
current_results = Stats.aggregate(site, query, metrics)
378-
prev_results = Stats.aggregate(site, prev_query, metrics)
390+
391+
prev_results =
392+
case Stats.Comparisons.compare(site, query, comparison_mode) do
393+
{:ok, prev_results_query} -> Stats.aggregate(site, prev_results_query, metrics)
394+
{:error, :not_supported} -> nil
395+
end
379396

380397
stats =
381398
[
@@ -394,11 +411,11 @@ defmodule PlausibleWeb.Api.StatsController do
394411

395412
defp top_stats_entry(current_results, prev_results, name, key) do
396413
if current_results[key] do
397-
%{
398-
name: name,
399-
value: current_results[key][:value],
400-
change: calculate_change(key, prev_results[key][:value], current_results[key][:value])
401-
}
414+
value = get_in(current_results, [key, :value])
415+
prev_value = get_in(prev_results, [key, :value])
416+
change = prev_value && calculate_change(key, prev_value, value)
417+
418+
%{name: name, value: value, change: change}
402419
end
403420
end
404421

lib/workers/send_email_report.ex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ defmodule Plausible.Workers.SendEmailReport do
4949
end
5050

5151
defp send_report(email, site, name, unsubscribe_link, query) do
52-
prev_query = Query.shift_back(query, site)
52+
{:ok, prev_query} = Stats.Comparisons.compare(site, query, "previous_period")
5353
curr_period = Stats.aggregate(site, query, [:pageviews, :visitors, :bounce_rate])
5454
prev_period = Stats.aggregate(site, prev_query, [:pageviews, :visitors, :bounce_rate])
5555

0 commit comments

Comments
 (0)