+ ${tooltipData.label ?
+ `
${tooltipData.label}
${tooltipData.formattedValue}
-
+
` : ''}
${tooltipData.comparisonLabel ?
`
diff --git a/assets/js/dashboard/stats/graph/graph-util.js b/assets/js/dashboard/stats/graph/graph-util.js
index 7f438e4150..99f787f53f 100644
--- a/assets/js/dashboard/stats/graph/graph-util.js
+++ b/assets/js/dashboard/stats/graph/graph-util.js
@@ -40,17 +40,11 @@ export const LoadingState = {
isLoadedOrRefreshing: function (state) { return [this.loaded, this.refreshing].includes(state) }
}
-const buildComparisonDataset = function(comparisonPlot, presentIndex) {
+const buildComparisonDataset = function(comparisonPlot) {
if (!comparisonPlot) return []
- let data = [...comparisonPlot]
- if (presentIndex) {
- const dashedPartIncludedIndex = presentIndex + 1
- data = data.slice(0, dashedPartIncludedIndex)
- }
-
return [{
- data: data,
+ data: comparisonPlot,
borderColor: 'rgba(60,70,110,0.2)',
pointBackgroundColor: 'rgba(60,70,110,0.2)',
pointHoverBackgroundColor: 'rgba(60, 70, 110)',
@@ -98,7 +92,7 @@ export const buildDataSet = (plot, comparisonPlot, present_index, ctx, label) =>
const dataset = [
...buildMainPlotDataset(plot, present_index),
...buildDashedDataset(plot, present_index),
- ...buildComparisonDataset(comparisonPlot, present_index)
+ ...buildComparisonDataset(comparisonPlot)
]
return dataset.map((item) => Object.assign(item, defaultOptions))
diff --git a/assets/js/dashboard/stats/graph/visitor-graph.js b/assets/js/dashboard/stats/graph/visitor-graph.js
index d0567485fe..6c101444df 100644
--- a/assets/js/dashboard/stats/graph/visitor-graph.js
+++ b/assets/js/dashboard/stats/graph/visitor-graph.js
@@ -91,14 +91,14 @@ class LineGraph extends React.Component {
ticks: {
maxTicksLimit: 8,
callback: function (val, _index, _ticks) {
- // realtime graph labels are not date strings
- const hasMultipleYears = typeof graphData.labels[0] !== 'string' ? false :
- graphData.labels
- // date format: 'yyyy-mm-dd'; maps to -> 'yyyy'
- .map(date => date.split('-')[0])
- // reject any year that appears at a previous index, unique years only
- .filter((value, index, list) => list.indexOf(value) === index)
- .length > 1
+ if (this.getLabelForValue(val) == "__blank__") return ""
+
+ const hasMultipleYears =
+ graphData.labels
+ .filter((date) => typeof date === 'string')
+ .map(date => date.split('-')[0])
+ .filter((value, index, list) => list.indexOf(value) === index)
+ .length > 1
if (graphData.interval === 'hour' && query.period !== 'day') {
const date = dateFormatter({
@@ -220,26 +220,12 @@ class LineGraph extends React.Component {
onClick(e) {
const element = this.chart.getElementsAtEventForMode(e, 'index', { intersect: false })[0]
- const date = this.chart.data.labels[element.index]
+ const date = this.props.graphData.labels[element.index] || this.props.graphData.comparison_labels[element.index]
if (this.props.graphData.interval === 'month') {
- navigateToQuery(
- this.props.history,
- this.props.query,
- {
- period: 'month',
- date,
- }
- )
+ navigateToQuery(this.props.history, this.props.query, { period: 'month', date })
} else if (this.props.graphData.interval === 'date') {
- navigateToQuery(
- this.props.history,
- this.props.query,
- {
- period: 'day',
- date,
- }
- )
+ navigateToQuery(this.props.history, this.props.query, { period: 'day', date })
}
}
diff --git a/assets/js/dashboard/util/date.js b/assets/js/dashboard/util/date.js
index a3d0964818..c0c0fd8f20 100644
--- a/assets/js/dashboard/util/date.js
+++ b/assets/js/dashboard/util/date.js
@@ -40,6 +40,10 @@ export function formatYear(date) {
return `Year of ${date.getFullYear()}`;
}
+export function formatYearShort(date) {
+ return date.getUTCFullYear().toString().substring(2)
+}
+
export function formatDay(date) {
var weekday = DAYS_ABBREV[date.getDay()];
if (date.getFullYear() !== (new Date()).getFullYear()) {
@@ -49,8 +53,13 @@ export function formatDay(date) {
}
}
-export function formatDayShort(date) {
- return `${date.getDate()} ${formatMonthShort(date)}`;
+export function formatDayShort(date, includeYear = false) {
+ let formatted = `${date.getDate()} ${formatMonthShort(date)}`
+ if (includeYear) {
+ formatted += ` ${formatYearShort(date)}`
+ }
+
+ return formatted
}
export function parseUTCDate(dateString) {
diff --git a/lib/plausible/stats/comparisons.ex b/lib/plausible/stats/comparisons.ex
index 0fd993650c..e3d009a0b9 100644
--- a/lib/plausible/stats/comparisons.ex
+++ b/lib/plausible/stats/comparisons.ex
@@ -9,40 +9,64 @@ defmodule Plausible.Stats.Comparisons do
alias Plausible.Stats
- @modes ~w(previous_period year_over_year)
+ @modes ~w(previous_period year_over_year custom)
@disallowed_periods ~w(realtime all)
@type mode() :: String.t() | nil
+ @typep option() :: {:from, String.t()} | {:to, String.t()} | {:now, NaiveDateTime.t()}
- @spec compare(
- Plausible.Site.t(),
- Stats.Query.t(),
- mode(),
- NaiveDateTime.t() | nil
- ) :: {:ok, Stats.Query.t()} | {:error, :not_supported}
- def compare(
- %Plausible.Site{} = site,
- %Stats.Query{} = source_query,
- mode,
- now \\ nil
- ) do
+ @spec compare(Plausible.Site.t(), Stats.Query.t(), mode(), [option()]) ::
+ {:ok, Stats.Query.t()} | {:error, :not_supported} | {:error, :invalid_dates}
+ @doc """
+ Generates a comparison query based on the source query and comparison mode.
+
+ The mode parameter specifies the type of comparison and can be one of the
+ following:
+
+ * `"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.
+
+ * `"custom"` - compares the query using a custom date range. See options for
+ more details.
+
+ The comparison query returned by the function has its end date restricted to
+ the current day. This can be overriden by the `now` option, described below.
+
+ ## Options
+
+ * `:now` - a `NaiveDateTime` struct with the current date and time. This is
+ optional and used for testing purposes.
+
+ * `:from` - a ISO-8601 date string used when mode is `"custom"`.
+
+ * `:to` - a ISO-8601 date string used when mode is `"custom"`. Must be
+ after `from`.
+
+ """
+ def compare(%Plausible.Site{} = site, %Stats.Query{} = source_query, mode, opts \\ []) do
if valid_mode?(source_query, mode) do
- now = now || Timex.now(site.timezone)
- {:ok, do_compare(source_query, mode, now)}
+ opts = Keyword.put_new(opts, :now, Timex.now(site.timezone))
+ do_compare(source_query, mode, opts)
else
{:error, :not_supported}
end
end
- defp do_compare(source_query, "year_over_year", now) do
+ defp do_compare(source_query, "year_over_year", opts) do
+ now = Keyword.fetch!(opts, :now)
+
start_date = Date.add(source_query.date_range.first, -365)
end_date = earliest(source_query.date_range.last, now) |> Date.add(-365)
range = Date.range(start_date, end_date)
- %Stats.Query{source_query | date_range: range}
+ {:ok, %Stats.Query{source_query | date_range: range}}
end
- defp do_compare(source_query, "previous_period", now) do
+ defp do_compare(source_query, "previous_period", opts) do
+ now = Keyword.fetch!(opts, :now)
+
last = earliest(source_query.date_range.last, now)
diff_in_days = Date.diff(source_query.date_range.first, last) - 1
@@ -50,7 +74,17 @@ defmodule Plausible.Stats.Comparisons do
new_last = Date.add(last, diff_in_days)
range = Date.range(new_first, new_last)
- %Stats.Query{source_query | date_range: range}
+ {:ok, %Stats.Query{source_query | date_range: range}}
+ end
+
+ defp do_compare(source_query, "custom", opts) do
+ with {:ok, from} <- opts |> Keyword.fetch!(:from) |> Date.from_iso8601(),
+ {:ok, to} <- opts |> Keyword.fetch!(:to) |> Date.from_iso8601(),
+ result when result in [:eq, :lt] <- Date.compare(from, to) do
+ {:ok, %Stats.Query{source_query | date_range: Date.range(from, to)}}
+ else
+ _error -> {:error, :invalid_dates}
+ end
end
defp earliest(a, b) do
diff --git a/lib/plausible_web/controllers/api/stats_controller.ex b/lib/plausible_web/controllers/api/stats_controller.ex
index 7df5e1a854..9a1172d930 100644
--- a/lib/plausible_web/controllers/api/stats_controller.ex
+++ b/lib/plausible_web/controllers/api/stats_controller.ex
@@ -110,21 +110,25 @@ defmodule PlausibleWeb.Api.StatsController do
end
timeseries_result = Stats.timeseries(site, timeseries_query, [selected_metric])
- labels = label_timeseries(timeseries_result)
- present_index = present_index_for(site, query, labels)
- full_intervals = build_full_intervals(query, labels)
comparison_result =
- case Comparisons.compare(site, query, params["comparison"]) do
+ case Comparisons.compare(site, query, params["comparison"],
+ from: params["compare_from"],
+ to: params["compare_to"]
+ ) do
{:ok, comparison_query} -> Stats.timeseries(site, comparison_query, [selected_metric])
{:error, :not_supported} -> nil
end
+ labels = label_timeseries(timeseries_result, comparison_result)
+ present_index = present_index_for(site, query, labels)
+ full_intervals = build_full_intervals(query, labels)
+
json(conn, %{
plot: plot_timeseries(timeseries_result, selected_metric),
labels: labels,
comparison_plot: comparison_result && plot_timeseries(comparison_result, selected_metric),
- comparison_labels: comparison_result && label_timeseries(comparison_result),
+ comparison_labels: comparison_result && label_timeseries(comparison_result, nil),
present_index: present_index,
interval: query.interval,
with_imported: query.include_imported,
@@ -140,8 +144,20 @@ defmodule PlausibleWeb.Api.StatsController do
Enum.map(timeseries, fn row -> row[metric] || 0 end)
end
- defp label_timeseries(timeseries) do
- Enum.map(timeseries, & &1.date)
+ defp label_timeseries(main_result, nil) do
+ Enum.map(main_result, & &1.date)
+ end
+
+ @blank_value "__blank__"
+ defp label_timeseries(main_result, comparison_result) do
+ blanks_to_fill = Enum.count(comparison_result) - Enum.count(main_result)
+
+ if blanks_to_fill > 0 do
+ blanks = List.duplicate(@blank_value, blanks_to_fill)
+ Enum.map(main_result, & &1.date) ++ blanks
+ else
+ Enum.map(main_result, & &1.date)
+ end
end
defp build_full_intervals(%{interval: "week", date_range: range}, labels) do
@@ -175,9 +191,11 @@ defmodule PlausibleWeb.Api.StatsController do
with :ok <- validate_params(params) do
comparison_mode = params["comparison"] || "previous_period"
+ comparison_opts = [from: params["compare_from"], to: params["compare_to"]]
+
query = Query.from(site, params) |> Filters.add_prefix()
- {top_stats, sample_percent} = fetch_top_stats(site, query, comparison_mode)
+ {top_stats, sample_percent} = fetch_top_stats(site, query, comparison_mode, comparison_opts)
json(conn, %{
top_stats: top_stats,
@@ -245,7 +263,8 @@ defmodule PlausibleWeb.Api.StatsController do
defp fetch_top_stats(
site,
%Query{period: "realtime", filters: %{"event:goal" => _goal}} = query,
- _comparison_mode
+ _comparison_mode,
+ _comparison_opts
) do
query_30m = %Query{query | period: "30m"}
@@ -272,7 +291,12 @@ defmodule PlausibleWeb.Api.StatsController do
{stats, 100}
end
- defp fetch_top_stats(site, %Query{period: "realtime"} = query, _comparison_mode) do
+ defp fetch_top_stats(
+ site,
+ %Query{period: "realtime"} = query,
+ _comparison_mode,
+ _comparison_opts
+ ) do
query_30m = %Query{query | period: "30m"}
%{
@@ -298,11 +322,16 @@ defmodule PlausibleWeb.Api.StatsController do
{stats, 100}
end
- defp fetch_top_stats(site, %Query{filters: %{"event:goal" => _goal}} = query, comparison_mode) do
+ defp fetch_top_stats(
+ site,
+ %Query{filters: %{"event:goal" => _goal}} = query,
+ comparison_mode,
+ comparison_opts
+ ) do
total_q = Query.remove_event_filters(query, [:goal, :props])
{prev_converted_visitors, prev_completions} =
- case Stats.Comparisons.compare(site, query, comparison_mode) do
+ case Stats.Comparisons.compare(site, query, comparison_mode, comparison_opts) do
{:ok, prev_query} ->
%{visitors: %{value: prev_converted_visitors}, events: %{value: prev_completions}} =
Stats.aggregate(site, prev_query, [:visitors, :events])
@@ -362,7 +391,7 @@ defmodule PlausibleWeb.Api.StatsController do
{stats, 100}
end
- defp fetch_top_stats(site, query, comparison_mode) do
+ defp fetch_top_stats(site, query, comparison_mode, comparison_opts) do
metrics =
if query.filters["event:page"] do
[
@@ -389,7 +418,7 @@ defmodule PlausibleWeb.Api.StatsController do
current_results = Stats.aggregate(site, query, metrics)
prev_results =
- case Stats.Comparisons.compare(site, query, comparison_mode) do
+ case Stats.Comparisons.compare(site, query, comparison_mode, comparison_opts) do
{:ok, prev_results_query} -> Stats.aggregate(site, prev_results_query, metrics)
{:error, :not_supported} -> nil
end
diff --git a/test/plausible/stats/comparisons_test.exs b/test/plausible/stats/comparisons_test.exs
index e4e28e9b36..bbf474819f 100644
--- a/test/plausible/stats/comparisons_test.exs
+++ b/test/plausible/stats/comparisons_test.exs
@@ -2,91 +2,91 @@ defmodule Plausible.Stats.ComparisonsTest do
use Plausible.DataCase
alias Plausible.Stats.{Query, Comparisons}
- describe "this month" do
- test "shifts back this month period" do
+ describe "with period set to this month" do
+ test "shifts back this month period when 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)
+ {:ok, comparison} = Comparisons.compare(site, query, "previous_period", now: now)
assert comparison.date_range.first == ~D[2023-02-27]
assert comparison.date_range.last == ~D[2023-02-28]
end
- test "shifts back this month period when it's the first day of the month" do
+ test "shifts back this month period when it's the first day of the month and mode is previous_period" do
site = build(:site)
query = Query.from(site, %{"period" => "month", "date" => "2023-03-01"})
now = ~N[2023-03-01 14:00:00]
- {:ok, comparison} = Comparisons.compare(site, query, "previous_period", now)
+ {:ok, comparison} = Comparisons.compare(site, query, "previous_period", now: now)
assert comparison.date_range.first == ~D[2023-02-28]
assert comparison.date_range.last == ~D[2023-02-28]
end
end
- describe "previous month" do
- test "shifts back using the same number of days when previous_period" do
+ describe "with period set to previous month" do
+ test "shifts back using the same number of days when mode is previous_period" 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)
+ {:ok, comparison} = Comparisons.compare(site, query, "previous_period", now: now)
assert comparison.date_range.first == ~D[2023-01-04]
assert comparison.date_range.last == ~D[2023-01-31]
end
- test "shifts back the full month when year_over_year" do
+ test "shifts back the full month when mode is year_over_year" 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, "year_over_year", now)
+ {:ok, comparison} = Comparisons.compare(site, query, "year_over_year", now: now)
assert comparison.date_range.first == ~D[2022-02-01]
assert comparison.date_range.last == ~D[2022-02-28]
end
- test "shifts back whole month plus one day when year_over_year and a leap year" do
+ test "shifts back whole month plus one day when mode is year_over_year and a leap year" do
site = build(:site)
query = Query.from(site, %{"period" => "month", "date" => "2020-02-01"})
now = ~N[2023-03-01 14:00:00]
- {:ok, comparison} = Comparisons.compare(site, query, "year_over_year", now)
+ {:ok, comparison} = Comparisons.compare(site, query, "year_over_year", now: now)
assert comparison.date_range.first == ~D[2019-02-01]
assert comparison.date_range.last == ~D[2019-03-01]
end
end
- describe "year to date" do
- test "shifts back by the same number of days when previous_period" do
+ describe "with period set to year to date" do
+ test "shifts back by the same number of days when mode is previous_period" 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, "previous_period", now)
+ {:ok, comparison} = Comparisons.compare(site, query, "previous_period", now: now)
assert comparison.date_range.first == ~D[2022-11-02]
assert comparison.date_range.last == ~D[2022-12-31]
end
- test "shifts back by the same number of days when year_over_year" do
+ test "shifts back by the same number of days 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)
+ {:ok, comparison} = Comparisons.compare(site, query, "year_over_year", now: now)
assert comparison.date_range.first == ~D[2022-01-01]
assert comparison.date_range.last == ~D[2022-03-01]
end
end
- describe "previous year" do
- test "shifts back a whole year when year_over_year" do
+ describe "with period set to previous year" do
+ test "shifts back a whole year when mode is year_over_year" do
site = build(:site)
query = Query.from(site, %{"period" => "year", "date" => "2022-03-02"})
@@ -96,7 +96,7 @@ defmodule Plausible.Stats.ComparisonsTest do
assert comparison.date_range.last == ~D[2021-12-31]
end
- test "shifts back a whole year when previous_period" do
+ test "shifts back a whole year when mode is previous_period" do
site = build(:site)
query = Query.from(site, %{"period" => "year", "date" => "2022-03-02"})
@@ -107,8 +107,8 @@ defmodule Plausible.Stats.ComparisonsTest do
end
end
- describe "custom" do
- test "shifts back by the same number of days when previous_period" do
+ describe "with period set to custom" do
+ test "shifts back by the same number of days when mode is previous_period" do
site = build(:site)
query = Query.from(site, %{"period" => "custom", "date" => "2023-01-01,2023-01-07"})
@@ -118,7 +118,7 @@ defmodule Plausible.Stats.ComparisonsTest do
assert comparison.date_range.last == ~D[2022-12-31]
end
- test "shifts back to last year when year_over_year" do
+ test "shifts back to last year when mode is year_over_year" do
site = build(:site)
query = Query.from(site, %{"period" => "custom", "date" => "2023-01-01,2023-01-07"})
@@ -128,4 +128,28 @@ defmodule Plausible.Stats.ComparisonsTest do
assert comparison.date_range.last == ~D[2022-01-07]
end
end
+
+ describe "with mode set to custom" do
+ test "sets first and last dates" do
+ site = build(:site)
+ query = Query.from(site, %{"period" => "custom", "date" => "2023-01-01,2023-01-07"})
+
+ {:ok, comparison} =
+ Comparisons.compare(site, query, "custom", from: "2022-05-25", to: "2022-05-30")
+
+ assert comparison.date_range.first == ~D[2022-05-25]
+ assert comparison.date_range.last == ~D[2022-05-30]
+ end
+
+ test "validates from and to dates" do
+ site = build(:site)
+ query = Query.from(site, %{"period" => "custom", "date" => "2023-01-01,2023-01-07"})
+
+ assert {:error, :invalid_dates} ==
+ Comparisons.compare(site, query, "custom", from: "2022-05-41", to: "2022-05-30")
+
+ assert {:error, :invalid_dates} ==
+ Comparisons.compare(site, query, "custom", from: "2022-05-30", to: "2022-05-25")
+ end
+ end
end
diff --git a/test/plausible_web/controllers/api/stats_controller/main_graph_test.exs b/test/plausible_web/controllers/api/stats_controller/main_graph_test.exs
index 5a719282a4..175871521d 100644
--- a/test/plausible_web/controllers/api/stats_controller/main_graph_test.exs
+++ b/test/plausible_web/controllers/api/stats_controller/main_graph_test.exs
@@ -806,5 +806,28 @@ defmodule PlausibleWeb.Api.StatsController.MainGraphTest do
assert 1 == Enum.at(plot, 30)
assert 1 == Enum.at(comparison_plot, 30)
end
+
+ test "fill in gaps when custom comparison period is larger than original query", %{
+ conn: conn,
+ site: site
+ } do
+ populate_stats(site, [
+ build(:pageview, timestamp: ~N[2020-01-01 00:00:00]),
+ build(:pageview, timestamp: ~N[2020-01-05 00:00:00]),
+ build(:pageview, timestamp: ~N[2020-01-30 00:00:00])
+ ])
+
+ conn =
+ get(
+ conn,
+ "/api/stats/#{site.domain}/main-graph?period=month&date=2020-01-01&comparison=custom&compare_from=2022-01-01&compare_to=2022-06-01"
+ )
+
+ assert %{"labels" => labels, "comparison_plot" => comparison_labels} =
+ json_response(conn, 200)
+
+ assert length(labels) == length(comparison_labels)
+ assert "__blank__" == List.last(labels)
+ end
end
end