File size: 9,156 Bytes
8da2481
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
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
  alias Plausible.Stats.{DateTimeRange, Query, Time}
  alias Plausible.Times

  @spec get_comparison_utc_time_range(Stats.Query.t()) :: DateTimeRange.t()
  @doc """
  Generates a `DateTimeRange` representing the comparison period of a given
  `%Query{}` struct (i.e. the `source_query`).

  There are different modes and options that determine the outcome of the
  resulting DateTimeRange. Those are specified under `source_query.include`.

  Currently only historical periods are supported for comparisons (not `realtime`
  and `30m` periods).

  ## Modes (`source_query.include.compare` field)

    * `:previous_period` - shifts back the query by the same number of days the
        source query has.

    * `:year_over_year` - shifts back the query by 1 year.

    * `{:date_range, from, to}` - compares the query using a custom date range.

  ## Options

    * `source_query.include.compare_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.

      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.

      Note: this option has no effect when custom date range mode is used.

  """
  def get_comparison_utc_time_range(%Stats.Query{} = source_query) do
    datetime_range =
      case source_query.include.compare do
        {:datetime_range, from, to} ->
          DateTimeRange.new!(from, to)

        _ ->
          # For 24h period and today, work directly with datetime ranges to preserve time precision
          if use_datetime_for_comparison?(source_query) do
            get_comparison_datetime_range(source_query)
          else
            comparison_date_range = get_comparison_date_range(source_query)

            DateTimeRange.new!(
              comparison_date_range.first,
              comparison_date_range.last,
              source_query.timezone
            )
          end
      end

    DateTimeRange.to_timezone(datetime_range, "Etc/UTC")
  end

  defp use_datetime_for_comparison?(query) do
    if query.input_date_range == :day do
      today_from_now = Times.to_date(query.now, query.timezone)
      day_from_range = Times.to_date(query.utc_time_range.first, query.timezone)

      Date.compare(today_from_now, day_from_range) == :eq
    else
      query.input_date_range == :"24h"
    end
  end

  def get_comparison_query(
        %Query{comparison_utc_time_range: %DateTimeRange{} = comparison_range} = source_query
      ) do
    source_query
    |> Query.set(utc_time_range: comparison_range)
  end

  @doc """
  Builds comparison query that specifically filters for values appearing in the main query results.

  When querying for comparisons with dimensions and pagination, extra
  filters are added to ensure comparison query returns same set of results
  as main query.
  """
  def add_comparison_filters(comparison_query, main_results_list) do
    comparison_filters =
      Enum.flat_map(main_results_list, &build_comparison_filter(&1, comparison_query))

    comparison_query
    |> add_query_filters(comparison_filters)
  end

  defp add_query_filters(query, []), do: query

  defp add_query_filters(query, [filter]) do
    query
    |> Query.add_filter([:ignore_in_totals_query, filter])
    |> Query.set(pagination: nil)
  end

  defp add_query_filters(query, filters) do
    query
    |> Query.add_filter([:ignore_in_totals_query, [:or, filters]])
    |> Query.set(pagination: nil)
  end

  defp build_comparison_filter(%{dimensions: dimension_labels}, query) do
    query_filters =
      query.dimensions
      |> Enum.zip(dimension_labels)
      |> Enum.reject(fn {dimension, _label} -> Time.time_dimension?(dimension) end)
      |> Enum.map(fn {dimension, label} -> [:is, dimension, [label]] end)

    case query_filters do
      [] -> []
      [filter] -> [filter]
      filters -> [[:and, filters]]
    end
  end

  # For 24h and today periods, shift the datetime range directly to preserve time precision
  defp get_comparison_datetime_range(
         %Query{
           input_date_range: input_range,
           include: %{compare: :previous_period} = include
         } = source_query
       )
       when input_range in [:"24h", :day] do
    offset =
      if include.compare_match_day_of_week do
        [day: -7]
      else
        [hour: -24]
      end

    comparison_start = DateTime.shift(source_query.utc_time_range.first, offset)
    comparison_end = DateTime.shift(source_query.utc_time_range.last, offset)

    DateTimeRange.new!(comparison_start, comparison_end)
  end

  defp get_comparison_datetime_range(
         %Query{
           input_date_range: input_range,
           include: %{compare: :year_over_year} = include
         } = source_query
       )
       when input_range in [:"24h", :day] do
    comparison_start = DateTime.shift(source_query.utc_time_range.first, year: -1)
    comparison_end = DateTime.shift(source_query.utc_time_range.last, year: -1)

    if include.compare_match_day_of_week do
      source_first_date = Times.to_date(source_query.utc_time_range.first, source_query.timezone)
      comparison_first_date = Times.to_date(comparison_start, source_query.timezone)

      day_to_match = Date.day_of_week(source_first_date)
      matched_date = shift_to_nearest(day_to_match, comparison_first_date, source_first_date)
      days_shifted = Date.diff(matched_date, comparison_first_date)

      DateTimeRange.new!(
        DateTime.shift(comparison_start, day: days_shifted),
        DateTime.shift(comparison_end, day: days_shifted)
      )
    else
      DateTimeRange.new!(comparison_start, comparison_end)
    end
  end

  defp get_comparison_datetime_range(
         %Query{
           input_date_range: input_range,
           include: %{compare: {:date_range, from_date, to_date}}
         } = source_query
       )
       when input_range in [:"24h", :day] do
    DateTimeRange.new!(from_date, to_date, source_query.timezone)
  end

  defp get_comparison_date_range(%Query{include: %{compare: :year_over_year}} = source_query) do
    source_date_range = Query.date_range(source_query, trim_trailing: true)

    start_date = source_date_range.first |> Date.shift(year: -1)
    diff_in_days = Date.diff(source_date_range.last, source_date_range.first)
    end_date = Date.add(start_date, diff_in_days)

    Date.range(start_date, end_date)
    |> maybe_match_day_of_week(source_date_range, source_query)
  end

  defp get_comparison_date_range(%Query{include: %{compare: :previous_period}} = source_query) do
    source_date_range = Query.date_range(source_query, trim_trailing: true)

    last = source_date_range.last
    diff_in_days = Date.diff(source_date_range.first, last) - 1

    new_first = Date.add(source_date_range.first, diff_in_days)
    new_last = Date.add(last, diff_in_days)

    Date.range(new_first, new_last)
    |> maybe_match_day_of_week(source_date_range, source_query)
  end

  defp get_comparison_date_range(%Query{include: %{compare: {:date_range, from_date, to_date}}}) do
    Date.range(from_date, to_date)
  end

  defp maybe_match_day_of_week(comparison_date_range, source_date_range, source_query) do
    if source_query.include.compare_match_day_of_week do
      day_to_match = Date.day_of_week(source_date_range.first)

      new_first =
        shift_to_nearest(
          day_to_match,
          comparison_date_range.first,
          source_date_range.first
        )

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

      Date.range(new_first, new_last)
    else
      comparison_date_range
    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
end