Skip to content

Commit 3d656ae

Browse files
authored
Match day of the week feature for comparisons (#2822)
* Add support for `match_day_of_week?` back-end option * Add match day of week input to comparison input * Default match day of the week to true
1 parent 825a754 commit 3d656ae

File tree

6 files changed

+171
-10
lines changed

6 files changed

+171
-10
lines changed

Diff for: assets/js/dashboard/api.js

+1
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ export function serializeQuery(query, extraQuery=[]) {
5050
queryObj.comparison = query.comparison
5151
queryObj.compare_from = query.compare_from ? formatISO(query.compare_from) : undefined
5252
queryObj.compare_to = query.compare_to ? formatISO(query.compare_to) : undefined
53+
queryObj.match_day_of_week = query.match_day_of_week
5354
}
5455

5556
Object.assign(queryObj, ...extraQuery)

Diff for: assets/js/dashboard/comparison-input.js

+37-2
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ const DEFAULT_COMPARISON_MODE = 'previous_period'
1919

2020
export const COMPARISON_DISABLED_PERIODS = ['realtime', 'all']
2121

22+
export const getStoredMatchDayOfWeek = function(domain) {
23+
return storage.getItem(`comparison_match_day_of_week__${domain}`) || 'true'
24+
}
25+
2226
export const getStoredComparisonMode = function(domain) {
2327
const mode = storage.getItem(`comparison_mode__${domain}`)
2428
if (Object.keys(COMPARISON_MODES).includes(mode)) {
@@ -53,7 +57,7 @@ export const toggleComparisons = function(history, query, site) {
5357
}
5458
}
5559

56-
function DropdownItem({ label, value, isCurrentlySelected, updateMode, setUiMode }) {
60+
function ComparisonModeOption({ label, value, isCurrentlySelected, updateMode, setUiMode }) {
5761
const click = () => {
5862
if (value == "custom") {
5963
setUiMode("datepicker")
@@ -80,6 +84,33 @@ function DropdownItem({ label, value, isCurrentlySelected, updateMode, setUiMode
8084
)
8185
}
8286

87+
function MatchDayOfWeekInput({ history, query, site }) {
88+
const click = (matchDayOfWeek) => {
89+
storage.setItem(`comparison_match_day_of_week__${site.domain}`, matchDayOfWeek.toString())
90+
navigateToQuery(history, query, { match_day_of_week: matchDayOfWeek.toString() })
91+
}
92+
93+
const buttonClass = (hover, selected) =>
94+
classNames("px-4 py-2 w-full text-left font-medium text-sm dark:text-white cursor-pointer", {
95+
"bg-gray-100 text-gray-900 dark:bg-gray-900 dark:text-gray-100": hover,
96+
"font-bold": selected,
97+
})
98+
99+
return <>
100+
<Menu.Item key="match_day_of_week" onClick={() => click(true)}>
101+
{({ active }) => (
102+
<button className={buttonClass(active, query.match_day_of_week)}>Match day of the week</button>
103+
)}
104+
</Menu.Item>
105+
106+
<Menu.Item key="match_exact_date" onClick={() => click(false)}>
107+
{({ active }) => (
108+
<button className={buttonClass(active, !query.match_day_of_week)}>Match exact date</button>
109+
)}
110+
</Menu.Item>
111+
</>
112+
}
113+
83114
const ComparisonInput = function({ site, query, history }) {
84115
if (!site.flags.comparisons) return null
85116
if (COMPARISON_DISABLED_PERIODS.includes(query.period)) return null
@@ -137,7 +168,11 @@ const ComparisonInput = function({ site, query, history }) {
137168
leaveFrom="transform opacity-100 scale-100"
138169
leaveTo="transform opacity-0 scale-95">
139170
<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>
140-
{ Object.keys(COMPARISON_MODES).map((key) => DropdownItem({ label: COMPARISON_MODES[key], value: key, isCurrentlySelected: key == query.comparison, updateMode, setUiMode })) }
171+
{ Object.keys(COMPARISON_MODES).map((key) => ComparisonModeOption({ label: COMPARISON_MODES[key], value: key, isCurrentlySelected: key == query.comparison, updateMode, setUiMode })) }
172+
{ query.comparison !== "custom" && <span>
173+
<hr className="my-1" />
174+
<MatchDayOfWeekInput query={query} history={history} site={site} />
175+
</span>}
141176
</Menu.Items>
142177
</Transition>
143178

Diff for: assets/js/dashboard/query.js

+4-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import React from 'react'
22
import { Link, withRouter } from 'react-router-dom'
33
import {nowForSite} from './util/date'
44
import * as storage from './util/storage'
5-
import { COMPARISON_DISABLED_PERIODS, getStoredComparisonMode, isComparisonEnabled } from './comparison-input'
5+
import { COMPARISON_DISABLED_PERIODS, getStoredComparisonMode, isComparisonEnabled,getStoredMatchDayOfWeek } from './comparison-input'
66

77
import dayjs from 'dayjs';
88
import utc from 'dayjs/plugin/utc';
@@ -27,6 +27,8 @@ export function parseQuery(querystring, site) {
2727
let comparison = q.get('comparison') || getStoredComparisonMode(site.domain)
2828
if (COMPARISON_DISABLED_PERIODS.includes(period) || !isComparisonEnabled(comparison)) comparison = null
2929

30+
let matchDayOfWeek = q.get('match_day_of_week') || getStoredMatchDayOfWeek(site.domain)
31+
3032
return {
3133
period,
3234
comparison,
@@ -35,6 +37,7 @@ export function parseQuery(querystring, site) {
3537
date: q.get('date') ? dayjs.utc(q.get('date')) : nowForSite(site),
3638
from: q.get('from') ? dayjs.utc(q.get('from')) : undefined,
3739
to: q.get('to') ? dayjs.utc(q.get('to')) : undefined,
40+
match_day_of_week: matchDayOfWeek == 'true',
3841
with_imported: q.get('with_imported') ? q.get('with_imported') === 'true' : true,
3942
filters: {
4043
'goal': q.get('goal'),

Diff for: lib/plausible/stats/comparisons.ex

+69-2
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,15 @@ defmodule Plausible.Stats.Comparisons do
4444
* `:to` - a ISO-8601 date string used when mode is `"custom"`. Must be
4545
after `from`.
4646
47+
* `:match_day_of_week?` - determines whether the comparison query should be
48+
adjusted to match the day of the week of the source query. When this option
49+
is set to true, the comparison query is shifted to start on the same day of
50+
the week as the source query, rather than on the exact same date. For
51+
example, if the source query starts on Sunday, January 1st, 2023 and the
52+
`year_over_year` comparison query is configured to `match_day_of_week?`,
53+
it will be shifted to start on Sunday, January 2nd, 2022 instead of
54+
January 1st. Defaults to false.
55+
4756
"""
4857
def compare(%Plausible.Site{} = site, %Stats.Query{} = source_query, mode, opts \\ []) do
4958
if valid_mode?(source_query, mode) do
@@ -61,7 +70,13 @@ defmodule Plausible.Stats.Comparisons do
6170
end_date = earliest(source_query.date_range.last, now) |> Date.add(-365)
6271

6372
range = Date.range(start_date, end_date)
64-
{:ok, %Stats.Query{source_query | date_range: range}}
73+
74+
comparison_query =
75+
source_query
76+
|> Map.put(:date_range, range)
77+
|> maybe_match_day_of_week(source_query, opts)
78+
79+
{:ok, comparison_query}
6580
end
6681

6782
defp do_compare(source_query, "previous_period", opts) do
@@ -74,7 +89,13 @@ defmodule Plausible.Stats.Comparisons do
7489
new_last = Date.add(last, diff_in_days)
7590

7691
range = Date.range(new_first, new_last)
77-
{:ok, %Stats.Query{source_query | date_range: range}}
92+
93+
comparison_query =
94+
source_query
95+
|> Map.put(:date_range, range)
96+
|> maybe_match_day_of_week(source_query, opts)
97+
98+
{:ok, comparison_query}
7899
end
79100

80101
defp do_compare(source_query, "custom", opts) do
@@ -91,6 +112,52 @@ defmodule Plausible.Stats.Comparisons do
91112
if Date.compare(a, b) in [:eq, :lt], do: a, else: b
92113
end
93114

115+
defp maybe_match_day_of_week(comparison_query, source_query, opts) do
116+
if Keyword.get(opts, :match_day_of_week?, false) do
117+
day_to_match = Date.day_of_week(source_query.date_range.first)
118+
119+
new_first =
120+
shift_to_nearest(
121+
day_to_match,
122+
comparison_query.date_range.first,
123+
source_query.date_range.first
124+
)
125+
126+
days_shifted = Date.diff(new_first, comparison_query.date_range.first)
127+
new_last = Date.add(comparison_query.date_range.last, days_shifted)
128+
129+
new_range = Date.range(new_first, new_last)
130+
%Stats.Query{comparison_query | date_range: new_range}
131+
else
132+
comparison_query
133+
end
134+
end
135+
136+
defp shift_to_nearest(day_of_week, date, reject) do
137+
if Date.day_of_week(date) == day_of_week do
138+
date
139+
else
140+
[next_occurring(day_of_week, date), previous_occurring(day_of_week, date)]
141+
|> Enum.sort_by(&Date.diff(date, &1))
142+
|> Enum.reject(&(&1 == reject))
143+
|> List.first()
144+
end
145+
end
146+
147+
defp next_occurring(day_of_week, date) do
148+
days_to_add = day_of_week - Date.day_of_week(date)
149+
days_to_add = if days_to_add > 0, do: days_to_add, else: days_to_add + 7
150+
151+
Date.add(date, days_to_add)
152+
end
153+
154+
defp previous_occurring(day_of_week, date) do
155+
days_to_subtract = Date.day_of_week(date) - day_of_week
156+
days_to_subtract = if days_to_subtract > 0, do: days_to_subtract, else: days_to_subtract + 7
157+
158+
Date.add(date, -days_to_subtract)
159+
end
160+
94161
@spec valid_mode?(Stats.Query.t(), mode()) :: boolean()
95162
@doc """
96163
Returns whether the source query and the selected mode support comparisons.

Diff for: lib/plausible_web/controllers/api/stats_controller.ex

+12-5
Original file line numberDiff line numberDiff line change
@@ -111,11 +111,10 @@ defmodule PlausibleWeb.Api.StatsController do
111111

112112
timeseries_result = Stats.timeseries(site, timeseries_query, [selected_metric])
113113

114+
comparison_opts = parse_comparison_opts(params)
115+
114116
comparison_result =
115-
case Comparisons.compare(site, query, params["comparison"],
116-
from: params["compare_from"],
117-
to: params["compare_to"]
118-
) do
117+
case Comparisons.compare(site, query, params["comparison"], comparison_opts) do
119118
{:ok, comparison_query} -> Stats.timeseries(site, comparison_query, [selected_metric])
120119
{:error, :not_supported} -> nil
121120
end
@@ -193,7 +192,7 @@ defmodule PlausibleWeb.Api.StatsController do
193192
query = Query.from(site, params) |> Filters.add_prefix()
194193

195194
comparison_mode = params["comparison"] || "previous_period"
196-
comparison_opts = [from: params["compare_from"], to: params["compare_to"]]
195+
comparison_opts = parse_comparison_opts(params)
197196

198197
comparison_query =
199198
case Stats.Comparisons.compare(site, query, comparison_mode, comparison_opts) do
@@ -1316,4 +1315,12 @@ defmodule PlausibleWeb.Api.StatsController do
13161315
|> put_status(400)
13171316
|> json(%{error: message})
13181317
end
1318+
1319+
defp parse_comparison_opts(params) do
1320+
[
1321+
from: params["compare_from"],
1322+
to: params["compare_to"],
1323+
match_day_of_week?: params["match_day_of_week"] == "true"
1324+
]
1325+
end
13191326
end

Diff for: test/plausible/stats/comparisons_test.exs

+48
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,18 @@ defmodule Plausible.Stats.ComparisonsTest do
2424
assert comparison.date_range.first == ~D[2023-02-28]
2525
assert comparison.date_range.last == ~D[2023-02-28]
2626
end
27+
28+
test "matches the day of the week when nearest day is original query start date and mode is previous_period" do
29+
site = build(:site)
30+
query = Query.from(site, %{"period" => "month", "date" => "2023-03-02"})
31+
now = ~N[2023-03-02 14:00:00]
32+
33+
{:ok, comparison} =
34+
Comparisons.compare(site, query, "previous_period", now: now, match_day_of_week?: true)
35+
36+
assert comparison.date_range.first == ~D[2023-02-22]
37+
assert comparison.date_range.last == ~D[2023-02-23]
38+
end
2739
end
2840

2941
describe "with period set to previous month" do
@@ -59,6 +71,30 @@ defmodule Plausible.Stats.ComparisonsTest do
5971
assert comparison.date_range.first == ~D[2019-02-01]
6072
assert comparison.date_range.last == ~D[2019-03-01]
6173
end
74+
75+
test "matches the day of the week when mode is previous_period keeping the same day" do
76+
site = build(:site)
77+
query = Query.from(site, %{"period" => "month", "date" => "2023-02-01"})
78+
now = ~N[2023-03-01 14:00:00]
79+
80+
{:ok, comparison} =
81+
Comparisons.compare(site, query, "previous_period", now: now, match_day_of_week?: true)
82+
83+
assert comparison.date_range.first == ~D[2023-01-04]
84+
assert comparison.date_range.last == ~D[2023-01-31]
85+
end
86+
87+
test "matches the day of the week when mode is previous_period" do
88+
site = build(:site)
89+
query = Query.from(site, %{"period" => "month", "date" => "2023-01-01"})
90+
now = ~N[2023-03-01 14:00:00]
91+
92+
{:ok, comparison} =
93+
Comparisons.compare(site, query, "previous_period", now: now, match_day_of_week?: true)
94+
95+
assert comparison.date_range.first == ~D[2022-12-04]
96+
assert comparison.date_range.last == ~D[2023-01-03]
97+
end
6298
end
6399

64100
describe "with period set to year to date" do
@@ -83,6 +119,18 @@ defmodule Plausible.Stats.ComparisonsTest do
83119
assert comparison.date_range.first == ~D[2022-01-01]
84120
assert comparison.date_range.last == ~D[2022-03-01]
85121
end
122+
123+
test "matches the day of the week when mode is year_over_year" do
124+
site = build(:site)
125+
query = Query.from(site, %{"period" => "year", "date" => "2023-03-01"})
126+
now = ~N[2023-03-01 14:00:00]
127+
128+
{:ok, comparison} =
129+
Comparisons.compare(site, query, "year_over_year", now: now, match_day_of_week?: true)
130+
131+
assert comparison.date_range.first == ~D[2022-01-02]
132+
assert comparison.date_range.last == ~D[2022-03-02]
133+
end
86134
end
87135

88136
describe "with period set to previous year" do

0 commit comments

Comments
 (0)