Skip to content

Commit

Permalink
Match day of the week feature for comparisons (#2822)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
vinibrsl authored Apr 13, 2023
1 parent 825a754 commit 3d656ae
Show file tree
Hide file tree
Showing 6 changed files with 171 additions and 10 deletions.
1 change: 1 addition & 0 deletions assets/js/dashboard/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export function serializeQuery(query, extraQuery=[]) {
queryObj.comparison = query.comparison
queryObj.compare_from = query.compare_from ? formatISO(query.compare_from) : undefined
queryObj.compare_to = query.compare_to ? formatISO(query.compare_to) : undefined
queryObj.match_day_of_week = query.match_day_of_week
}

Object.assign(queryObj, ...extraQuery)
Expand Down
39 changes: 37 additions & 2 deletions assets/js/dashboard/comparison-input.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ const DEFAULT_COMPARISON_MODE = 'previous_period'

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

export const getStoredMatchDayOfWeek = function(domain) {
return storage.getItem(`comparison_match_day_of_week__${domain}`) || 'true'
}

export const getStoredComparisonMode = function(domain) {
const mode = storage.getItem(`comparison_mode__${domain}`)
if (Object.keys(COMPARISON_MODES).includes(mode)) {
Expand Down Expand Up @@ -53,7 +57,7 @@ export const toggleComparisons = function(history, query, site) {
}
}

function DropdownItem({ label, value, isCurrentlySelected, updateMode, setUiMode }) {
function ComparisonModeOption({ label, value, isCurrentlySelected, updateMode, setUiMode }) {
const click = () => {
if (value == "custom") {
setUiMode("datepicker")
Expand All @@ -80,6 +84,33 @@ function DropdownItem({ label, value, isCurrentlySelected, updateMode, setUiMode
)
}

function MatchDayOfWeekInput({ history, query, site }) {
const click = (matchDayOfWeek) => {
storage.setItem(`comparison_match_day_of_week__${site.domain}`, matchDayOfWeek.toString())
navigateToQuery(history, query, { match_day_of_week: matchDayOfWeek.toString() })
}

const buttonClass = (hover, selected) =>
classNames("px-4 py-2 w-full text-left font-medium text-sm dark:text-white cursor-pointer", {
"bg-gray-100 text-gray-900 dark:bg-gray-900 dark:text-gray-100": hover,
"font-bold": selected,
})

return <>
<Menu.Item key="match_day_of_week" onClick={() => click(true)}>
{({ active }) => (
<button className={buttonClass(active, query.match_day_of_week)}>Match day of the week</button>
)}
</Menu.Item>

<Menu.Item key="match_exact_date" onClick={() => click(false)}>
{({ active }) => (
<button className={buttonClass(active, !query.match_day_of_week)}>Match exact date</button>
)}
</Menu.Item>
</>
}

const ComparisonInput = function({ site, query, history }) {
if (!site.flags.comparisons) return null
if (COMPARISON_DISABLED_PERIODS.includes(query.period)) return null
Expand Down Expand Up @@ -137,7 +168,11 @@ const ComparisonInput = function({ site, query, history }) {
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>
{ Object.keys(COMPARISON_MODES).map((key) => DropdownItem({ label: COMPARISON_MODES[key], value: key, isCurrentlySelected: key == query.comparison, updateMode, setUiMode })) }
{ Object.keys(COMPARISON_MODES).map((key) => ComparisonModeOption({ label: COMPARISON_MODES[key], value: key, isCurrentlySelected: key == query.comparison, updateMode, setUiMode })) }
{ query.comparison !== "custom" && <span>
<hr className="my-1" />
<MatchDayOfWeekInput query={query} history={history} site={site} />
</span>}
</Menu.Items>
</Transition>

Expand Down
5 changes: 4 additions & 1 deletion assets/js/dashboard/query.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import React from 'react'
import { Link, withRouter } from 'react-router-dom'
import {nowForSite} from './util/date'
import * as storage from './util/storage'
import { COMPARISON_DISABLED_PERIODS, getStoredComparisonMode, isComparisonEnabled } from './comparison-input'
import { COMPARISON_DISABLED_PERIODS, getStoredComparisonMode, isComparisonEnabled,getStoredMatchDayOfWeek } from './comparison-input'

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

let matchDayOfWeek = q.get('match_day_of_week') || getStoredMatchDayOfWeek(site.domain)

return {
period,
comparison,
Expand All @@ -35,6 +37,7 @@ export function parseQuery(querystring, site) {
date: q.get('date') ? dayjs.utc(q.get('date')) : nowForSite(site),
from: q.get('from') ? dayjs.utc(q.get('from')) : undefined,
to: q.get('to') ? dayjs.utc(q.get('to')) : undefined,
match_day_of_week: matchDayOfWeek == 'true',
with_imported: q.get('with_imported') ? q.get('with_imported') === 'true' : true,
filters: {
'goal': q.get('goal'),
Expand Down
71 changes: 69 additions & 2 deletions lib/plausible/stats/comparisons.ex
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,15 @@ defmodule Plausible.Stats.Comparisons do
* `:to` - a ISO-8601 date string used when mode is `"custom"`. Must be
after `from`.
* `:match_day_of_week?` - determines whether the comparison query should be
adjusted to match the day of the week of the source query. When this option
is set to true, the comparison query is shifted to start on the same day of
the week as the source query, rather than on the exact same date. For
example, if the source query starts on Sunday, January 1st, 2023 and the
`year_over_year` comparison query is configured to `match_day_of_week?`,
it will be shifted to start on Sunday, January 2nd, 2022 instead of
January 1st. Defaults to false.
"""
def compare(%Plausible.Site{} = site, %Stats.Query{} = source_query, mode, opts \\ []) do
if valid_mode?(source_query, mode) do
Expand All @@ -61,7 +70,13 @@ defmodule Plausible.Stats.Comparisons do
end_date = earliest(source_query.date_range.last, now) |> Date.add(-365)

range = Date.range(start_date, end_date)
{:ok, %Stats.Query{source_query | date_range: range}}

comparison_query =
source_query
|> Map.put(:date_range, range)
|> maybe_match_day_of_week(source_query, opts)

{:ok, comparison_query}
end

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

range = Date.range(new_first, new_last)
{:ok, %Stats.Query{source_query | date_range: range}}

comparison_query =
source_query
|> Map.put(:date_range, range)
|> maybe_match_day_of_week(source_query, opts)

{:ok, comparison_query}
end

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

defp maybe_match_day_of_week(comparison_query, source_query, opts) do
if Keyword.get(opts, :match_day_of_week?, false) do
day_to_match = Date.day_of_week(source_query.date_range.first)

new_first =
shift_to_nearest(
day_to_match,
comparison_query.date_range.first,
source_query.date_range.first
)

days_shifted = Date.diff(new_first, comparison_query.date_range.first)
new_last = Date.add(comparison_query.date_range.last, days_shifted)

new_range = Date.range(new_first, new_last)
%Stats.Query{comparison_query | date_range: new_range}
else
comparison_query
end
end

defp shift_to_nearest(day_of_week, date, reject) do
if Date.day_of_week(date) == day_of_week do
date
else
[next_occurring(day_of_week, date), previous_occurring(day_of_week, date)]
|> Enum.sort_by(&Date.diff(date, &1))
|> Enum.reject(&(&1 == reject))
|> List.first()
end
end

defp next_occurring(day_of_week, date) do
days_to_add = day_of_week - Date.day_of_week(date)
days_to_add = if days_to_add > 0, do: days_to_add, else: days_to_add + 7

Date.add(date, days_to_add)
end

defp previous_occurring(day_of_week, date) do
days_to_subtract = Date.day_of_week(date) - day_of_week
days_to_subtract = if days_to_subtract > 0, do: days_to_subtract, else: days_to_subtract + 7

Date.add(date, -days_to_subtract)
end

@spec valid_mode?(Stats.Query.t(), mode()) :: boolean()
@doc """
Returns whether the source query and the selected mode support comparisons.
Expand Down
17 changes: 12 additions & 5 deletions lib/plausible_web/controllers/api/stats_controller.ex
Original file line number Diff line number Diff line change
Expand Up @@ -111,11 +111,10 @@ defmodule PlausibleWeb.Api.StatsController do

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

comparison_opts = parse_comparison_opts(params)

comparison_result =
case Comparisons.compare(site, query, params["comparison"],
from: params["compare_from"],
to: params["compare_to"]
) do
case Comparisons.compare(site, query, params["comparison"], comparison_opts) do
{:ok, comparison_query} -> Stats.timeseries(site, comparison_query, [selected_metric])
{:error, :not_supported} -> nil
end
Expand Down Expand Up @@ -193,7 +192,7 @@ defmodule PlausibleWeb.Api.StatsController do
query = Query.from(site, params) |> Filters.add_prefix()

comparison_mode = params["comparison"] || "previous_period"
comparison_opts = [from: params["compare_from"], to: params["compare_to"]]
comparison_opts = parse_comparison_opts(params)

comparison_query =
case Stats.Comparisons.compare(site, query, comparison_mode, comparison_opts) do
Expand Down Expand Up @@ -1316,4 +1315,12 @@ defmodule PlausibleWeb.Api.StatsController do
|> put_status(400)
|> json(%{error: message})
end

defp parse_comparison_opts(params) do
[
from: params["compare_from"],
to: params["compare_to"],
match_day_of_week?: params["match_day_of_week"] == "true"
]
end
end
48 changes: 48 additions & 0 deletions test/plausible/stats/comparisons_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,18 @@ defmodule Plausible.Stats.ComparisonsTest do
assert comparison.date_range.first == ~D[2023-02-28]
assert comparison.date_range.last == ~D[2023-02-28]
end

test "matches the day of the week when nearest day is original query start date and mode is previous_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: now, match_day_of_week?: true)

assert comparison.date_range.first == ~D[2023-02-22]
assert comparison.date_range.last == ~D[2023-02-23]
end
end

describe "with period set to previous month" do
Expand Down Expand Up @@ -59,6 +71,30 @@ defmodule Plausible.Stats.ComparisonsTest do
assert comparison.date_range.first == ~D[2019-02-01]
assert comparison.date_range.last == ~D[2019-03-01]
end

test "matches the day of the week when mode is previous_period keeping the same day" 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: now, match_day_of_week?: true)

assert comparison.date_range.first == ~D[2023-01-04]
assert comparison.date_range.last == ~D[2023-01-31]
end

test "matches the day of the week when mode is previous_period" do
site = build(:site)
query = Query.from(site, %{"period" => "month", "date" => "2023-01-01"})
now = ~N[2023-03-01 14:00:00]

{:ok, comparison} =
Comparisons.compare(site, query, "previous_period", now: now, match_day_of_week?: true)

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

describe "with period set to year to date" do
Expand All @@ -83,6 +119,18 @@ defmodule Plausible.Stats.ComparisonsTest do
assert comparison.date_range.first == ~D[2022-01-01]
assert comparison.date_range.last == ~D[2022-03-01]
end

test "matches the day of the week when mode is 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: now, match_day_of_week?: true)

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

describe "with period set to previous year" do
Expand Down

0 comments on commit 3d656ae

Please sign in to comment.