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