defmodule Plausible.Stats.QueryPeriod do @moduledoc """ Builds the time range covered by a stats query from parsed parameters. """ alias Plausible.Stats.DateTimeRange alias Plausible.Times @doc """ Resolves an `input_date_range` for the site and builds the corresponding `%DateTimeRange{}` anchored to the site's timezone. `relative_date` may be `nil`, in which case it defaults to today in the site's timezone. """ def build_range_for_site(input_date_range, %Plausible.Site{} = site, relative_date, now) do relative_date = relative_date || Times.to_date(now, site.timezone) input_date_range |> resolve_input_date_range(site, relative_date) |> build_datetime_range(site.timezone, relative_date, now) end @doc """ For some query periods, `now` or `relative_date` or both are irrelevant for resolving the date range. This dispatcher ensures that """ def build_datetime_range(:realtime, timezone, _relative_date, now), do: build_realtime_range(timezone, now, 5) def build_datetime_range(:realtime_30m, timezone, _relative_date, now), do: build_realtime_range(timezone, now, 30) def build_datetime_range(:day, timezone, relative_date, now), do: build_day_range(timezone, relative_date, now) def build_datetime_range(:"24h", timezone, _relative_date, now), do: build_24h_range(timezone, now) def build_datetime_range(:month, timezone, relative_date, _now), do: build_month_range(timezone, relative_date) def build_datetime_range(:year, timezone, relative_date, _now), do: build_year_range(timezone, relative_date) def build_datetime_range({:last_n_days, n}, timezone, relative_date, _now), do: build_last_n_days_range(timezone, n, relative_date) def build_datetime_range({:last_n_months, n}, timezone, relative_date, _now), do: build_last_n_months_range(timezone, n, relative_date) def build_datetime_range({:date_range, from, to}, timezone, _relative_date, _now), do: build_date_range(timezone, from, to) def build_datetime_range({:datetime_range, from, to}, timezone, _relative_date, _now), do: build_range_from_datetimes(timezone, from, to) @doc """ Resolves any site-dependent shape (currently only `:all`, which needs the site's stats start date) into a shape `build_datetime_range/4` can handle from a timezone alone. Other shapes pass through unchanged. """ def resolve_input_date_range(:all, %Plausible.Site{} = site, relative_date) do start_date = Plausible.Sites.stats_start_date(site) || relative_date {:date_range, start_date, relative_date} end def resolve_input_date_range(input_date_range, _site, _relative_date), do: input_date_range @doc """ Builds a realtime window of `duration_minutes` ending slightly after `now`. ## Examples iex> QueryPeriod.build_realtime_range("Etc/UTC", ~U[2026-05-05 12:30:00Z], 5) %DateTimeRange{first: ~U[2026-05-05 12:25:00Z], last: ~U[2026-05-05 12:30:05Z]} iex> QueryPeriod.build_realtime_range("Etc/UTC", ~U[2026-05-05 12:30:00Z], 30) %DateTimeRange{first: ~U[2026-05-05 12:00:00Z], last: ~U[2026-05-05 12:30:05Z]} """ def build_realtime_range(timezone, now, duration_minutes) do first_datetime = DateTime.shift(now, minute: -duration_minutes) last_datetime = DateTime.shift(now, second: 5) DateTimeRange.new!(first_datetime, last_datetime) |> DateTimeRange.to_timezone(timezone) end @doc """ Builds the range for a single calendar day. When `date` matches today in `timezone`, the range is truncated at `now`; otherwise it spans the full local day. ## Examples iex> QueryPeriod.build_day_range("Etc/UTC", ~D[2026-05-05], ~U[2026-05-05 12:30:00Z]) %DateTimeRange{first: ~U[2026-05-05 00:00:00Z], last: ~U[2026-05-05 12:30:00Z]} iex> QueryPeriod.build_day_range("Etc/UTC", ~D[2026-05-04], ~U[2999-01-01 00:00:00Z]) %DateTimeRange{first: ~U[2026-05-04 00:00:00Z], last: ~U[2026-05-04 23:59:59Z]} iex> QueryPeriod.build_day_range("Europe/Tallinn", ~D[2026-01-15], ~U[2999-01-01 00:00:00Z]) |> DateTimeRange.to_timezone("Etc/UTC") %DateTimeRange{first: ~U[2026-01-14 22:00:00Z], last: ~U[2026-01-15 21:59:59Z]} """ def build_day_range(timezone, date, now) do if Date.compare(Times.to_date(now, timezone), date) == :eq do DateTimeRange.new!(date, now, timezone) else DateTimeRange.new!(date, date, timezone) end end @doc """ Builds the 24-hour window ending at `now`. ## Examples iex> QueryPeriod.build_24h_range("Etc/UTC", ~U[2026-05-05 12:30:00Z]) %DateTimeRange{first: ~U[2026-05-04 12:30:00Z], last: ~U[2026-05-05 12:30:00Z]} """ def build_24h_range(timezone, now) do from = DateTime.shift(now, hour: -24) DateTimeRange.new!(from, now) |> DateTimeRange.to_timezone(timezone) end @doc """ Builds the range spanning the calendar month containing `date`. ## Examples iex> QueryPeriod.build_month_range("Etc/UTC", ~D[2026-05-15]) %DateTimeRange{first: ~U[2026-05-01 00:00:00Z], last: ~U[2026-05-31 23:59:59Z]} iex> QueryPeriod.build_month_range("Europe/Tallinn", ~D[2026-01-15]) |> DateTimeRange.to_timezone("Etc/UTC") %DateTimeRange{first: ~U[2025-12-31 22:00:00Z], last: ~U[2026-01-31 21:59:59Z]} """ def build_month_range(timezone, date) do first = Date.beginning_of_month(date) last = Date.end_of_month(date) DateTimeRange.new!(first, last, timezone) end @doc """ Builds the range spanning the calendar year containing `date`. ## Examples iex> QueryPeriod.build_year_range("Etc/UTC", ~D[2026-05-15]) %DateTimeRange{first: ~U[2026-01-01 00:00:00Z], last: ~U[2026-12-31 23:59:59Z]} iex> QueryPeriod.build_year_range("Europe/Tallinn", ~D[2026-05-15]) |> DateTimeRange.to_timezone("Etc/UTC") %DateTimeRange{first: ~U[2025-12-31 22:00:00Z], last: ~U[2026-12-31 21:59:59Z]} """ def build_year_range(timezone, date) do first = Times.beginning_of_year(date) last = Times.end_of_year(date) DateTimeRange.new!(first, last, timezone) end @doc """ Builds a range spanning `n` full calendar days ending the day before `end_date`. ## Examples iex> QueryPeriod.build_last_n_days_range("Etc/UTC", 7, ~D[2026-05-08]) %DateTimeRange{first: ~U[2026-05-01 00:00:00Z], last: ~U[2026-05-07 23:59:59Z]} iex> QueryPeriod.build_last_n_days_range("Europe/Tallinn", 7, ~D[2026-05-08]) |> DateTimeRange.to_timezone("Etc/UTC") %DateTimeRange{first: ~U[2026-04-30 21:00:00Z], last: ~U[2026-05-07 20:59:59Z]} """ def build_last_n_days_range(timezone, n, end_date) do last = Date.add(end_date, -1) first = Date.add(end_date, -n) DateTimeRange.new!(first, last, timezone) end @doc """ Builds a range spanning `n` full calendar months ending the month before `end_date`'s month. ## Examples iex> QueryPeriod.build_last_n_months_range("Etc/UTC", 3, ~D[2026-05-15]) %DateTimeRange{first: ~U[2026-02-01 00:00:00Z], last: ~U[2026-04-30 23:59:59Z]} iex> QueryPeriod.build_last_n_months_range("Europe/Tallinn", 3, ~D[2026-05-15]) |> DateTimeRange.to_timezone("Etc/UTC") %DateTimeRange{first: ~U[2026-01-31 22:00:00Z], last: ~U[2026-04-30 20:59:59Z]} """ def build_last_n_months_range(timezone, n, end_date) do last = end_date |> Date.shift(month: -1) |> Date.end_of_month() first = end_date |> Date.shift(month: -n) |> Date.beginning_of_month() DateTimeRange.new!(first, last, timezone) end @doc """ Builds a range from explicit start/end calendar dates, from start of first day to end of last day in `timezone`. ## Examples iex> QueryPeriod.build_date_range("Etc/UTC", ~D[2026-05-01], ~D[2026-05-10]) %DateTimeRange{first: ~U[2026-05-01 00:00:00Z], last: ~U[2026-05-10 23:59:59Z]} iex> QueryPeriod.build_date_range("Europe/Tallinn", ~D[2026-05-01], ~D[2026-05-10]) |> DateTimeRange.to_timezone("Etc/UTC") %DateTimeRange{first: ~U[2026-04-30 21:00:00Z], last: ~U[2026-05-10 20:59:59Z]} """ def build_date_range(timezone, from, to) do DateTimeRange.new!(from, to, timezone) end @doc """ Builds a range from explicit start/end datetimes, which may have any time zone, then shifts them to be in `timezone`. ## Examples iex> QueryPeriod.build_range_from_datetimes("Etc/UTC", ~U[2026-05-01 06:00:00Z], ~U[2026-05-01 18:00:00Z]) %DateTimeRange{first: ~U[2026-05-01 06:00:00Z], last: ~U[2026-05-01 18:00:00Z]} """ def build_range_from_datetimes(timezone, from, to) do DateTimeRange.new!(from, to) |> DateTimeRange.to_timezone(timezone) end end