Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions assets/js/liveview/dashboard_tabs.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,19 +16,19 @@ export default buildHook({

this.addListener('click', this.el, (e) => {
const button = e.target.closest('button')
const tab = button && button.dataset.tab
const tabKey = button && button.dataset.tabKey
const span = button && button.querySelector('span')

if (span && span.dataset.active === 'false') {
const label = button.dataset.label
const reportLabel = button.dataset.reportLabel
const storageKey = button.dataset.storageKey
const target = button.dataset.target
const tile = this.el.closest('[data-tile]')
const title = tile.querySelector('[data-title]')

title.innerText = label
title.innerText = reportLabel

this.el.querySelectorAll(`button[data-tab] span`).forEach((s) => {
this.el.querySelectorAll(`button[data-tab-key] span`).forEach((s) => {
this.js().setAttribute(s, 'data-active', 'false')
})

Expand All @@ -39,10 +39,10 @@ export default buildHook({
)

if (storageKey) {
localStorage.setItem(`${storageKey}__${domain}`, tab)
localStorage.setItem(`${storageKey}__${domain}`, tabKey)
}

this.pushEventTo(target, 'set-tab', { tab: tab })
this.pushEventTo(target, 'set-tab', { tab: tabKey })
}
})
}
Expand Down
7 changes: 6 additions & 1 deletion assets/js/liveview/live_socket.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,12 @@ if (csrfToken && websocketUrl) {
// The prefs are used by dashboard LiveView to persist
// user preferences across the reloads.
user_prefs: {
pages_tab: localStorage.getItem(`pageTab__${domainName}`)
pages_tab: localStorage.getItem(`pageTab__${domainName}`),
period: localStorage.getItem(`period__${domainName}`),
comparison: localStorage.getItem(`comparison_mode__${domainName}`),
match_day_of_week: localStorage.getItem(
`comparison_match_day_of_week__${domainName}`
)
},
_csrf_token: token
}
Expand Down
3 changes: 2 additions & 1 deletion lib/plausible/stats/api_query_parser.ex
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ defmodule Plausible.Stats.ApiQueryParser do
trim_relative_date_range: false,
compare: nil,
compare_match_day_of_week: false,
legacy_time_on_page_cutoff: nil
legacy_time_on_page_cutoff: nil,
dashboard_metric_labels: false
}

def default_include(), do: @default_include
Expand Down
98 changes: 72 additions & 26 deletions lib/plausible/stats/dashboard_query_parser.ex
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,13 @@ defmodule Plausible.Stats.DashboardQueryParser do
# might still want to know whether imported data can be toggled
# on/off on the dashboard.
imports_meta: true,
time_labels: true,
time_labels: false,
total_rows: false,
trim_relative_date_range: true,
compare: nil,
compare_match_day_of_week: true,
legacy_time_on_page_cutoff: nil
legacy_time_on_page_cutoff: nil,
dashboard_metric_labels: true
}

def default_include(), do: @default_include
Expand All @@ -28,48 +29,77 @@ defmodule Plausible.Stats.DashboardQueryParser do

def default_pagination(), do: @default_pagination

def parse(query_string, defaults \\ %{}) when is_binary(query_string) do
@valid_period_shorthands %{
"realtime" => :realtime,
"day" => :day,
"month" => :month,
"year" => :year,
"all" => :all,
"7d" => {:last_n_days, 7},
"28d" => {:last_n_days, 28},
"30d" => {:last_n_days, 30},
"91d" => {:last_n_days, 91},
"6mo" => {:last_n_months, 6},
"12mo" => {:last_n_months, 12}
}

@valid_period_shorthand_keys Map.keys(@valid_period_shorthands)

@valid_comparison_shorthands %{
"previous_period" => :previous_period,
"year_over_year" => :year_over_year
}

@valid_comparison_shorthand_keys Map.keys(@valid_comparison_shorthands)

def parse(query_string, site, user_prefs) when is_binary(query_string) do
query_string = String.trim_leading(query_string, "?")
params_map = Map.merge(defaults, URI.decode_query(query_string))
params_map = URI.decode_query(query_string)

with {:ok, filters} <- parse_filters(query_string),
{:ok, relative_date} <- parse_relative_date(params_map) do
input_date_range = parse_input_date_range(params_map, site, user_prefs)

include =
Map.merge(@default_include, %{
imports: parse_include_imports(params_map),
compare: parse_include_compare(params_map),
compare_match_day_of_week: parse_include_compare_match_day_of_week(params_map)
compare: parse_include_compare(params_map, user_prefs),
compare_match_day_of_week: parse_match_day_of_week(params_map, user_prefs)
})

{:ok,
ParsedQueryParams.new!(%{
input_date_range: parse_input_date_range(params_map),
input_date_range: input_date_range,
relative_date: relative_date,
filters: filters,
include: include
})}
end
end

defp parse_input_date_range(%{"period" => "realtime"}), do: :realtime
defp parse_input_date_range(%{"period" => "day"}), do: :day
defp parse_input_date_range(%{"period" => "month"}), do: :month
defp parse_input_date_range(%{"period" => "year"}), do: :year
defp parse_input_date_range(%{"period" => "all"}), do: :all
defp parse_input_date_range(%{"period" => "7d"}), do: {:last_n_days, 7}
defp parse_input_date_range(%{"period" => "28d"}), do: {:last_n_days, 28}
defp parse_input_date_range(%{"period" => "30d"}), do: {:last_n_days, 30}
defp parse_input_date_range(%{"period" => "91d"}), do: {:last_n_days, 91}
defp parse_input_date_range(%{"period" => "6mo"}), do: {:last_n_months, 6}
defp parse_input_date_range(%{"period" => "12mo"}), do: {:last_n_months, 12}
defp parse_input_date_range(%{"period" => period}, _site, _user_prefs)
when period in @valid_period_shorthand_keys do
@valid_period_shorthands[period]
end

defp parse_input_date_range(%{"period" => "custom", "from" => from, "to" => to}) do
defp parse_input_date_range(
%{"period" => "custom", "from" => from, "to" => to},
_site,
_user_prefs
) do
from_date = Date.from_iso8601!(String.trim(from))
to_date = Date.from_iso8601!(String.trim(to))
{:date_range, from_date, to_date}
end

defp parse_input_date_range(_), do: nil
defp parse_input_date_range(_params, _site, %{"period" => period})
when period in @valid_period_shorthand_keys do
@valid_period_shorthands[period]
end

defp parse_input_date_range(_params, site, _user_prefs) do
if recently_created?(site), do: :day, else: {:last_n_days, 28}
end

defp parse_relative_date(%{"date" => date}) do
case Date.from_iso8601(date) do
Expand All @@ -83,19 +113,30 @@ defmodule Plausible.Stats.DashboardQueryParser do
defp parse_include_imports(%{"with_imported" => "false"}), do: false
defp parse_include_imports(_), do: true

defp parse_include_compare(%{"comparison" => "previous_period"}), do: :previous_period
defp parse_include_compare(%{"comparison" => "year_over_year"}), do: :year_over_year
defp parse_include_compare(%{"comparison" => "off"}, _user_prefs), do: nil

defp parse_include_compare(%{"comparison" => comparison}, _user_prefs)
when comparison in @valid_comparison_shorthand_keys do
@valid_comparison_shorthands[comparison]
end

defp parse_include_compare(%{"comparison" => "custom"} = params) do
defp parse_include_compare(%{"comparison" => "custom"} = params, _user_prefs) do
from_date = Date.from_iso8601!(params["compare_from"])
to_date = Date.from_iso8601!(params["compare_to"])
{:date_range, from_date, to_date}
end

defp parse_include_compare(_options), do: nil
defp parse_include_compare(_params, %{"comparison" => comparison})
when comparison in @valid_comparison_shorthand_keys do
@valid_comparison_shorthands[comparison]
end

defp parse_include_compare(_params, _user_prefs), do: nil

defp parse_include_compare_match_day_of_week(%{"match_day_of_week" => "false"}), do: false
defp parse_include_compare_match_day_of_week(_), do: true
defp parse_match_day_of_week(%{"match_day_of_week" => "false"}, _user_prefs), do: false
defp parse_match_day_of_week(%{"match_day_of_week" => "true"}, _user_prefs), do: true
defp parse_match_day_of_week(_params, %{"match_day_of_week" => "false"}), do: false
defp parse_match_day_of_week(_params, _user_prefs), do: true

defp parse_filters(query_string) do
with {:ok, filters} <- decode_filters(query_string) do
Expand Down Expand Up @@ -155,4 +196,9 @@ defmodule Plausible.Stats.DashboardQueryParser do
defp event_dimension?(dimension) do
dimension in @event_dimensions or String.starts_with?(dimension, @event_props_prefix)
end

defp recently_created?(site) do
stats_start_date = NaiveDateTime.to_date(site.native_stats_start_at)
Date.diff(stats_start_date, Date.utc_today()) >= -1
end
end
4 changes: 2 additions & 2 deletions lib/plausible/stats/filters/filters.ex
Original file line number Diff line number Diff line change
Expand Up @@ -114,9 +114,9 @@ defmodule Plausible.Stats.Filters do
|> Enum.map(fn {[_operator, dimension | _rest], _depth} -> dimension end)
end

def filtering_on_dimension?(query, dimension, opts \\ []) do
def filtering_on_dimension?(query_or_filters, dimension, opts \\ []) do
filters =
case query do
case query_or_filters do
%Query{filters: filters} -> filters
%{filters: filters} -> filters
filters when is_list(filters) -> filters
Expand Down
13 changes: 13 additions & 0 deletions lib/plausible/stats/metrics.ex
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,17 @@ defmodule Plausible.Stats.Metrics do
def from_string(str) do
Map.fetch(@metric_mappings, str)
end

def dashboard_metric_label(:visitors, %{realtime?: true}), do: "Current visitors"
def dashboard_metric_label(:visitors, %{goal_filter?: true}), do: "Conversions"

def dashboard_metric_label(:visitors, %{dimensions: ["visit:entry_page"]}),
do: "Unique entrances"

def dashboard_metric_label(:visitors, %{dimensions: ["visit:exit_page"]}), do: "Unique exits"
def dashboard_metric_label(:visitors, _context), do: "Visitors"

def dashboard_metric_label(:conversion_rate, _context), do: "CR"

def dashboard_metric_label(metric, _context), do: "#{metric}"
end
7 changes: 7 additions & 0 deletions lib/plausible/stats/parsed_query_params.ex
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,11 @@ defmodule Plausible.Stats.ParsedQueryParams do

struct!(parsed_query_params, filters: new_filters)
end

def conversion_goal_filter?(%__MODULE__{filters: filters}) do
Plausible.Stats.Filters.filtering_on_dimension?(filters, "event:goal",
max_depth: 0,
behavioral_filters: :ignore
)
end
end
6 changes: 4 additions & 2 deletions lib/plausible/stats/query_include.ex
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ defmodule Plausible.Stats.QueryInclude do
trim_relative_date_range: false,
compare: nil,
compare_match_day_of_week: false,
legacy_time_on_page_cutoff: nil
legacy_time_on_page_cutoff: nil,
dashboard_metric_labels: false
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion, non-blocking: Since metrics are ordered in the response and the dashboard knows what it requested (choose_metrics), I think we should provide / map the labels there. Then this struct will only contain fields that influence the actual database query.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see anything wrong with keeping logic unrelated to the ClickHouse SQL query in Stats.query. There's also include.time_labels which has nothing to do with the DB query either.

However, I agree that the mapping part is unnecessary. I think you reviewed before I pushed this commit: 9f7695b. I've changed meta.metric_labels to be a returned as a list instead of map. That way the metric labels can just be used directly, like metric keys.

There's another edge case that will be a bit easier to handle this way. Revenue metrics are sometimes dropped silently (when mixing currencies in goal filters). So even though a LiveView report always asks for them, they might not be returned.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, it's actually weird that time labels don't go through the database. It's very database adjacent though.

I see your point about it making an edge case easier to handle, I disagree that it's worth the tradeoff (of making Query and its result more complex).

I suspect we'll be tempted to put a lot of configuration in QueryInclude. I think we should do so only for things that can't be easily handled another way. Metric labels can be. Still, it's not a blocker for merge.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wouldn't say this adds much complexity into the query function. A bit more code and a new input/response field, sure, but it doesn't make it more difficult to reason about.

But I see your concern because the %Query{} struct is quite messy and it's quite terrifying to look at it. It needs some cleaning up for sure, and it will happen in this project. There are some legacy fields that are not used anymore (interval, sample_threshold, include_imported, legacy_breakdown, maybe more...). And we also have a bunch of stuff there that gets slapped onto the query object somewhere along the pipeline for internal use only (site_id, site_native_stats_start_at, consolidated_site_ids, sql_join_type, etc...). All of those fields could be nested under a query.assigns field.

Copy link
Contributor

@apata apata Jan 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A bit more code and a new input/response field, sure, but it doesn't make it more difficult to reason about.

Whether something is easy or hard to reason about is quite hard to discuss.
I'd prefer we be specific.
Complexity is a bit better, though we can be even more specific (more/less code, more/less code paths, more/less fields).
If we add a feature/refactor, I suspect the question is not whether we want more complexity, it's where we want it.

My argument is that we don't want it in QueryInclude, here's why:

What's the responsibility of the Query module? As I see it, it's to represent a request for data, and must contain exactly what is needed to get that data from the database. QueryInclude is a set of options regarding that request for data. Much like filters, dimensions, metrics.

If we keep to this responsibility, we can say that as long as one doesn't touch Query and QueryInclude, there's no risk of regression regarding requests for data. This is the benefit that I'm trying to sell.

QueryResult serializes the data. Let's say its responsibility is also sometimes to get human readable names for the metrics queried (which IMO is debatable, maybe it should be a separate step before QueryResult). We don't have to pass this option through Query.include. It can be an argument of the function QueryResult.from.

Regarding cleanup, I'm not sure that assigns is the right way to go: that's a bit of an opaque misc drawer. The struct fields are at least explicit. I look at them this way: "these are the fields necessary to get data, they get populated somewhere, maybe, and when they do, they influence the result".

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I understand your point about separating responsibilities but I'm not seeing how this new include field could cause us any pain in the future. The benefit of not having to worry about metric mapping in every LiveView stats report module sounds far more real. That said though, the query refactoring work is still ahead and we can consider it further down the line.

Regarding cleanup, I'm not sure that assigns is the right way to go: that's a bit of an opaque misc drawer. The struct fields are at least explicit. I look at them this way: "these are the fields necessary to get data, they get populated somewhere, maybe, and when they do, they influence the result".

It could also be a nested struct with the fields explicitly defined with default values. The point would be to separate the input fields from those that are attached to the query automatically.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It could also be a nested struct with the fields explicitly defined with default values. The point would be to separate the input fields from those that are attached to the query automatically.

@RobertJoonas, that overall point is very good!

I understand your point about separating responsibilities but I'm not seeing how this new include field could cause us any pain in the future.

Thanks for considering it! This one field is definitely innocent and good code. It just got me thinking on why we need to do it this way. I believe by doing it a little bit differently, we can have the benefits that both of us brought up (shared code between LiveView stats reports, more singular responsibility for QueryInclude). To illustrate what I had in mind, I put together this #5983.


@type date_range_tuple() :: {:date_range, Date.t(), Date.t()}
@type datetime_range_tuple() :: {:datetime_range, DateTime.t(), DateTime.t()}
Expand All @@ -22,6 +23,7 @@ defmodule Plausible.Stats.QueryInclude do
compare:
nil | :previous_period | :year_over_year | date_range_tuple() | datetime_range_tuple(),
compare_match_day_of_week: boolean(),
legacy_time_on_page_cutoff: any()
legacy_time_on_page_cutoff: any(),
dashboard_metric_labels: boolean()
}
end
29 changes: 28 additions & 1 deletion lib/plausible/stats/query_result.ex
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ defmodule Plausible.Stats.QueryResult do
"""

use Plausible
alias Plausible.Stats.{Query, QueryRunner}
alias Plausible.Stats.{Query, QueryRunner, QueryInclude}

defstruct results: [],
meta: %{},
Expand Down Expand Up @@ -62,6 +62,7 @@ defmodule Plausible.Stats.QueryResult do
%{}
|> add_imports_meta(runner.main_query)
|> add_metric_warnings_meta(runner.main_query)
|> add_dashboard_metric_labels(runner.main_query)
|> add_time_labels_meta(runner.main_query)
|> add_total_rows_meta(runner.main_query, runner.total_rows)
end
Expand Down Expand Up @@ -90,6 +91,32 @@ defmodule Plausible.Stats.QueryResult do
end
end

defp add_dashboard_metric_labels(meta, %Query{
include: %QueryInclude{dashboard_metric_labels: false}
}) do
meta
end

defp add_dashboard_metric_labels(meta, query) do
context = %{
goal_filter?:
Plausible.Stats.Filters.filtering_on_dimension?(query, "event:goal",
max_depth: 0,
behavioral_filters: :ignore
),
realtime?: query.input_date_range in [:realtime, :realtime_30m],
dimensions: query.dimensions
}

metric_labels =
query.metrics
|> Enum.map(fn metric ->
Plausible.Stats.Metrics.dashboard_metric_label(metric, context)
end)

Map.put(meta, :metric_labels, metric_labels)
end

defp add_time_labels_meta(meta, query) do
if query.include.time_labels do
Map.put(meta, :time_labels, Plausible.Stats.Time.time_labels(query))
Expand Down
1 change: 1 addition & 0 deletions lib/plausible_web/live/components/dashboard/base.ex
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ defmodule PlausibleWeb.Components.Dashboard.Base do
<div class="w-full h-full relative" style={@style}>
<div
class={"absolute top-0 left-0 h-full rounded-sm transition-colors duration-150 #{@background_class || ""}"}
data-test-id="bar-indicator"
style={"width: #{@width_percent}%"}
>
</div>
Expand Down
Loading
Loading