From 9065ae87357c614518276f50a965a0d7b7c90ea9 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Tue, 16 Dec 2025 16:01:46 +0100 Subject: [PATCH 01/20] Adjust defaults in `DashboardQueryParser` --- lib/plausible/stats/dashboard_query_parser.ex | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/lib/plausible/stats/dashboard_query_parser.ex b/lib/plausible/stats/dashboard_query_parser.ex index bd50c91026d3..fe3387b9f224 100644 --- a/lib/plausible/stats/dashboard_query_parser.ex +++ b/lib/plausible/stats/dashboard_query_parser.ex @@ -14,7 +14,7 @@ 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, @@ -28,9 +28,9 @@ defmodule Plausible.Stats.DashboardQueryParser do def default_pagination(), do: @default_pagination - def parse(query_string) when is_binary(query_string) do + def parse(query_string, defaults \\ %{}) when is_binary(query_string) do query_string = String.trim_leading(query_string, "?") - params_map = URI.decode_query(query_string) + params_map = Map.merge(defaults, URI.decode_query(query_string)) with {:ok, filters} <- parse_filters(query_string), {:ok, relative_date} <- parse_relative_date(params_map) do @@ -43,6 +43,8 @@ defmodule Plausible.Stats.DashboardQueryParser do {:ok, ParsedQueryParams.new!(%{ + metrics: [], + dimensions: [], input_date_range: parse_input_date_range(params_map), relative_date: relative_date, filters: filters, From a3fa1b310f9b0cea95a7c4698d50fac38607f230 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Tue, 16 Dec 2025 16:03:02 +0100 Subject: [PATCH 02/20] Add dedicated link and bar components in `Base` --- .../live/components/dashboard/base.ex | 73 ++++++++++++++++++- 1 file changed, 71 insertions(+), 2 deletions(-) diff --git a/lib/plausible_web/live/components/dashboard/base.ex b/lib/plausible_web/live/components/dashboard/base.ex index 041cac215d92..29ebde0a73b7 100644 --- a/lib/plausible_web/live/components/dashboard/base.ex +++ b/lib/plausible_web/live/components/dashboard/base.ex @@ -5,14 +5,26 @@ defmodule PlausibleWeb.Components.Dashboard.Base do use PlausibleWeb, :component - attr :href, :string, required: true + alias Plausible.Stats.DashboardQuerySerializer + attr :site, Plausible.Site, required: true + attr :params, :map, required: true + attr :path, :string, default: "" attr :class, :string, default: "" attr :rest, :global + slot :inner_block, required: true def dashboard_link(assigns) do - url = "/" <> assigns.site.domain <> assigns.href + query_string = DashboardQuerySerializer.serialize(assigns.params) + url = "/" <> assigns.site.domain <> assigns.path + + url = + if query_string != "" do + url <> "?" <> query_string + else + url + end assigns = assign(assigns, :url, url) @@ -20,10 +32,67 @@ defmodule PlausibleWeb.Components.Dashboard.Base do <.link data-type="dashboard-link" patch={@url} + class={@class} {@rest} > {render_slot(@inner_block)} """ end + + attr :site, Plausible.Site, required: true + attr :params, :map, required: true + attr :filter, :list, required: true + attr :class, :string, default: "" + attr :rest, :global + + slot :inner_block, required: true + + def filter_link(assigns) do + params_string = replace_filter(assigns.params, assigns.filter) + + assigns = assign(assigns, :params_string, params_string) + + ~H""" + <.dashboard_link site={@site} params={@params} class={@class} {@rest}> + {render_slot(@inner_block)} + + """ + end + + attr :style, :string, default: "" + attr :background_class, :string, default: "" + attr :width, :integer, required: true + attr :max_width, :integer, required: true + + slot :inner_block, required: true + + def bar(assigns) do + width_percent = assigns.width / assigns.max_width * 100 + + assigns = assign(assigns, :width_percent, width_percent) + + ~H""" +
+
+
+ {render_slot(@inner_block)} +
+ """ + end + + defp replace_filter(params, filter) do + [:is, dimension, _values] = filter + + filters = + Enum.reject(params.filters, fn + {:is, ^dimension, _} -> true + _ -> false + end) + + %{params | filters: [filter | filters]} + end end From 47a56efbcbc5db56b78c8d365972b50a3bcdcbfe Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Tue, 16 Dec 2025 16:03:25 +0100 Subject: [PATCH 03/20] Introduce `Metric.value` component --- .../live/components/dashboard/metric.ex | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 lib/plausible_web/live/components/dashboard/metric.ex diff --git a/lib/plausible_web/live/components/dashboard/metric.ex b/lib/plausible_web/live/components/dashboard/metric.ex new file mode 100644 index 000000000000..62fa1f7e0bd6 --- /dev/null +++ b/lib/plausible_web/live/components/dashboard/metric.ex @@ -0,0 +1,60 @@ +defmodule PlausibleWeb.Components.Dashboard.Metric do + @moduledoc """ + Components for rendering metric data. + """ + + use PlausibleWeb, :component + + @formatters %{ + visitors: :number_short, + conversion_rate: :percentage + } + + attr :name, :atom, required: true + attr :value, :any + + def value(assigns) do + ~H""" +
+ {format_value(@name, @value)} +
+ """ + end + + defp format_value(name, value) do + apply_format(@formatters[name], value) + end + + @hundred_billion :math.pow(10, 11) + @billion :math.pow(10, 9) + @hundred_million :math.pow(10, 8) + @million :math.pow(10, 6) + @hundred_thousand :math.pow(10, 5) + @thousand :math.pow(10, 3) + + defp apply_format(:number_short, value) when is_number(value) do + cond do + value >= @hundred_billion -> divided(value, @billion) + value >= @billion -> divided(value, @billion, 2) + value >= @hundred_million -> divided(value, @million) + value >= @million -> divided(value, @million, 2) + value >= @hundred_thousand -> divided(value, @thousand) + value >= @thousand -> divided(value, @thousand, 2) + true -> value + end + end + + defp apply_format(:number_short, _), do: "-" + + defp apply_format(:percentage, value) do + if value do + :erlang.float_to_binary(value, decimals: 2) <> "%" + else + "-" + end + end + + defp divided(value, divisor, precision \\ 0) do + :erlang.float_to_binary(value / divisor, decimals: precision) + end +end From 7fd9d5dcc5e019fac2818218cd6d01ff7bf10423 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Tue, 16 Dec 2025 16:03:43 +0100 Subject: [PATCH 04/20] Implement basic `ReportList` component --- .../live/components/dashboard/base.ex | 4 +- .../live/components/dashboard/report_list.ex | 207 ++++++++++++++++++ 2 files changed, 209 insertions(+), 2 deletions(-) create mode 100644 lib/plausible_web/live/components/dashboard/report_list.ex diff --git a/lib/plausible_web/live/components/dashboard/base.ex b/lib/plausible_web/live/components/dashboard/base.ex index 29ebde0a73b7..77088e53cd77 100644 --- a/lib/plausible_web/live/components/dashboard/base.ex +++ b/lib/plausible_web/live/components/dashboard/base.ex @@ -49,9 +49,9 @@ defmodule PlausibleWeb.Components.Dashboard.Base do slot :inner_block, required: true def filter_link(assigns) do - params_string = replace_filter(assigns.params, assigns.filter) + params = replace_filter(assigns.params, assigns.filter) - assigns = assign(assigns, :params_string, params_string) + assigns = assign(assigns, :params, params) ~H""" <.dashboard_link site={@site} params={@params} class={@class} {@rest}> diff --git a/lib/plausible_web/live/components/dashboard/report_list.ex b/lib/plausible_web/live/components/dashboard/report_list.ex new file mode 100644 index 000000000000..e3c53846a5ea --- /dev/null +++ b/lib/plausible_web/live/components/dashboard/report_list.ex @@ -0,0 +1,207 @@ +defmodule PlausibleWeb.Components.Dashboard.ReportList do + @moduledoc """ + ReportList component. + """ + + use PlausibleWeb, :component + + alias PlausibleWeb.Components.Dashboard.Base + alias PlausibleWeb.Components.Dashboard.Metric + + @max_items 9 + @min_height 380 + @row_height 32 + @row_gap_height 4 + @data_container_height (@row_height + @row_gap_height) * (@max_items - 1) + @row_height + @col_min_width 70 + + def report(assigns) do + max_value = + assigns.results + |> Enum.map(& &1.visitors) + |> Enum.max() + + assigns = + assign(assigns, + max_value: max_value, + max_items: @max_items, + min_height: @min_height, + row_height: @row_height, + row_gap_height: @row_gap_height, + data_container_height: @data_container_height, + col_min_width: @col_min_width, + empty?: Enum.empty?(assigns.results) + ) + + ~H""" + <.no_data :if={@empty?} min_height={@min_height} /> +
+
+ <.report_header key_label={@key_label} metrics={@metrics} col_min_width={@col_min_width} /> +
+ +
+ <.report_row + :for={item <- @results} + link_fn={assigns[:external_link_fn]} + item={item} + metrics={@metrics} + bar_value={item.visitors} + bar_max_value={@max_value} + site={@site} + params={@params} + filter_dimension={@filter_dimension} + row_height={@row_height} + row_gap_height={@row_gap_height} + col_min_width={@col_min_width} + /> +
+ +
+ <.details_link + site={@site} + params={@params} + path="/pages" + /> +
+
+ """ + end + + defp no_data(assigns) do + ~H""" +
+
+ No data yet +
+
+ """ + end + + defp external_link(assigns) do + url = if(assigns[:link_fn], do: assigns.link_fn.(assigns.item)) + + assigns = assign(assigns, :url, url) + + ~H""" + <.link + :if={@url} + target="_blank" + rel="noreferrer" + href={@url} + class="w-4 h-4 invisible group-hover:visible" + > + + + + + + + + """ + end + + defp report_header(assigns) do + ~H""" +
+ {@key_label} +
+ {metric.label} +
+
+ """ + end + + def report_row(assigns) do + ~H""" +
+
+
+ +
+ + + {trim_name(@item.name, @col_min_width)} + + + <.external_link item={@item} link_fn={assigns[:link_fn]} /> +
+
+
+
+ + + +
+
+
+ """ + end + + defp details_link(assigns) do + ~H""" + + + + + DETAILS + + """ + end + + defp trim_name(name, max_length) do + if String.length(name) <= max_length do + name + else + left_length = div(max_length, 2) + right_length = max_length - left_length + + left_side = String.slice(name, 0..left_length) + right_side = String.slice(name, -right_length..-1) + + left_side <> "..." <> right_side + end + end +end From 223a973b6cffada142873a78151f6b867a945377 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Tue, 16 Dec 2025 16:43:50 +0100 Subject: [PATCH 05/20] Use `ReportList` component in LV pages breakdown --- lib/plausible_web/live/dashboard.ex | 27 +++- lib/plausible_web/live/dashboard/pages.ex | 179 ++++++++++++++++++++-- 2 files changed, 193 insertions(+), 13 deletions(-) diff --git a/lib/plausible_web/live/dashboard.ex b/lib/plausible_web/live/dashboard.ex index a1729f7355e2..9f9bc012add3 100644 --- a/lib/plausible_web/live/dashboard.ex +++ b/lib/plausible_web/live/dashboard.ex @@ -6,8 +6,15 @@ defmodule PlausibleWeb.Live.Dashboard do use PlausibleWeb, :live_view alias Plausible.Repo + alias Plausible.Stats.DashboardQueryParser + alias Plausible.Stats.QueryBuilder alias Plausible.Teams + @default_prefs %{ + "period" => "28d", + "match_day_of_week" => true + } + @spec enabled?(Plausible.Site.t() | nil) :: boolean() def enabled?(nil), do: false @@ -16,7 +23,8 @@ defmodule PlausibleWeb.Live.Dashboard do end def mount(_params, %{"domain" => domain, "url" => url}, socket) do - user_prefs = get_connect_params(socket)["user_prefs"] || %{} + # TODO: make it more permissive of invalid values in search params and stored values + user_prefs = Map.merge(@default_prefs, get_connect_params(socket)["user_prefs"] || %{}) # As domain is passed via session, the associated site has already passed # validation logic on plug level. @@ -34,14 +42,25 @@ defmodule PlausibleWeb.Live.Dashboard do |> assign(:connected?, connected?(socket)) |> assign(:site, site) |> assign(:user_prefs, user_prefs) - |> assign(:params, %{}) {:noreply, socket} = handle_params_internal(%{}, url, socket) {:ok, socket} end - def handle_params_internal(_params, _url, socket) do + def handle_params_internal(_params, url, socket) do + uri = URI.new!(url) + path = uri.path |> String.split("/") |> Enum.drop(2) + {:ok, params} = DashboardQueryParser.parse(uri.query || "", socket.assigns.user_prefs) + {:ok, query} = QueryBuilder.build(socket.assigns.site, params, %{}) + + socket = + assign(socket, + path: path, + params: params, + query: query + ) + {:noreply, socket} end @@ -55,6 +74,8 @@ defmodule PlausibleWeb.Live.Dashboard do site={@site} user_prefs={@user_prefs} connected?={@connected?} + params={@params} + query={@query} /> diff --git a/lib/plausible_web/live/dashboard/pages.ex b/lib/plausible_web/live/dashboard/pages.ex index fafb89282d42..20a89a3fa5a4 100644 --- a/lib/plausible_web/live/dashboard/pages.ex +++ b/lib/plausible_web/live/dashboard/pages.ex @@ -5,16 +5,80 @@ defmodule PlausibleWeb.Live.Dashboard.Pages do use PlausibleWeb, :live_component - alias PlausibleWeb.Components.Dashboard.Base + alias PlausibleWeb.Components.Dashboard.ReportList alias PlausibleWeb.Components.Dashboard.Tile + alias Plausible.Stats + alias Plausible.Stats.Filters + @tabs [ {"pages", "Top Pages"}, {"entry-pages", "Entry Pages"}, {"exit-pages", "Exit Pages"} ] - @tab_labels Map.new(@tabs) + @key_labels %{ + "pages" => "Page", + "entry-pages" => "Entry page", + "exit-pages" => "Exit page" + } + + @max_items 9 + @pagination_params {@max_items, 1} + + @metrics %{ + "pages" => %{ + visitors: %{ + width: "w-24", + key: :visitors, + label: "Visitors", + sortable: true, + plot: true + }, + conversion_rate: %{ + width: "w-24", + key: :conversion_rate, + label: "CR", + sortable: true + } + }, + "entry-pages" => %{ + visitors: %{ + width: "w-24", + key: :visitors, + label: "Unique Entrances", + sortable: true, + plot: true + }, + conversion_rate: %{ + width: "w-24", + key: :conversion_rate, + label: "CR", + sortable: true + } + }, + "exit-pages" => %{ + visitors: %{ + width: "w-24", + key: :visitors, + label: "Unique Exits", + sortable: true, + plot: true + }, + conversion_rate: %{ + width: "w-24", + key: :conversion_rate, + label: "CR", + sortable: true + } + } + } + + @filter_dimensions %{ + "pages" => "event:page", + "entry-pages" => "visit:entry_page", + "exit-pages" => "visit:exit_page" + } def update(assigns, socket) do active_tab = assigns.user_prefs["pages_tab"] || "pages" @@ -22,19 +86,24 @@ defmodule PlausibleWeb.Live.Dashboard.Pages do socket = assign(socket, site: assigns.site, + params: assigns.params, + query: assigns.query, tabs: @tabs, - tab_labels: @tab_labels, + key_labels: @key_labels, + filter_dimensions: @filter_dimensions, active_tab: active_tab, connected?: assigns.connected? ) + |> load_metrics() {:ok, socket} end def render(assigns) do + assigns = assign(assigns, :external_link_fn, &external_link/1) ~H"""
- + <:tabs> -
- - Filter by source Direct / None - -
+
""" @@ -57,11 +131,96 @@ defmodule PlausibleWeb.Live.Dashboard.Pages do def handle_event("set-tab", %{"tab" => tab}, socket) do if tab != socket.assigns.active_tab do - socket = assign(socket, :active_tab, tab) + socket = + socket + |> assign(:active_tab, tab) + |> load_metrics() {:noreply, socket} else {:noreply, socket} end end + + defp external_link(_item) do + "https://example.com" + end + + defp load_metrics(socket) do + %{results: pages, meta: meta, metrics: metrics} = + metrics_for_tab(socket.assigns.active_tab, socket.assigns.site, socket.assigns.query) + + assign( + socket, + metrics: Enum.map(metrics, &Map.fetch!(@metrics[socket.assigns.active_tab], &1)), + results: Enum.take(pages, @max_items), + meta: Map.merge(meta, Stats.Breakdown.formatted_date_ranges(socket.assigns.query)), + skip_imported_reason: meta[:imports_skip_reason] + ) + end + + defp metrics_for_tab("pages", site, query) do + query = struct!(query, dimensions: ["event:page"]) + + metrics = breakdown_metrics(query) + + %{results: results, meta: meta} = Stats.breakdown(site, query, metrics, @pagination_params) + + pages = + results + |> transform_keys(%{page: :name}) + + %{results: pages, meta: meta, metrics: metrics} + end + + defp metrics_for_tab("entry-pages", site, query) do + query = struct!(query, dimensions: ["visit:entry_page"]) + + metrics = breakdown_metrics(query) + + %{results: results, meta: meta} = Stats.breakdown(site, query, metrics, @pagination_params) + + pages = + results + |> transform_keys(%{entry_page: :name}) + + %{results: pages, meta: meta, metrics: metrics} + end + + defp metrics_for_tab("exit-pages", site, query) do + query = struct!(query, dimensions: ["visit:exit_page"]) + + metrics = breakdown_metrics(query) + + %{results: results, meta: meta} = Stats.breakdown(site, query, metrics, @pagination_params) + + pages = + results + |> transform_keys(%{exit_page: :name}) + + %{results: pages, meta: meta, metrics: metrics} + end + + defp breakdown_metrics(query) do + if toplevel_goal_filter?(query) do + [:visitors, :conversion_rate] + else + [:visitors] + end + end + + defp transform_keys(result, keys_to_replace) when is_map(result) do + for {key, val} <- result, do: {Map.get(keys_to_replace, key, key), val}, into: %{} + end + + defp transform_keys(results, keys_to_replace) when is_list(results) do + Enum.map(results, &transform_keys(&1, keys_to_replace)) + end + + defp toplevel_goal_filter?(query) do + Filters.filtering_on_dimension?(query, "event:goal", + max_depth: 0, + behavioral_filters: :ignore + ) + end end From 62dd558de85df81796063e7ca896f57436276781 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Wed, 17 Dec 2025 11:29:46 +0100 Subject: [PATCH 06/20] Make tile styling more in line with react one --- lib/plausible_web/live/components/dashboard/tile.ex | 3 ++- lib/plausible_web/live/dashboard/pages.ex | 8 +++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/lib/plausible_web/live/components/dashboard/tile.ex b/lib/plausible_web/live/components/dashboard/tile.ex index b09ef681d7fd..0e21724b1d99 100644 --- a/lib/plausible_web/live/components/dashboard/tile.ex +++ b/lib/plausible_web/live/components/dashboard/tile.ex @@ -6,6 +6,7 @@ defmodule PlausibleWeb.Components.Dashboard.Tile do use PlausibleWeb, :component attr :id, :string, required: true + attr :class, :string, default: "" attr :title, :string, required: true # Optimistic rendering requires preventing LV patching of # title and tabs. The update of those is handled by `tab` @@ -17,7 +18,7 @@ defmodule PlausibleWeb.Components.Dashboard.Tile do def tile(assigns) do ~H""" -
+
"-title"} class="flex gap-x-1" phx-update="ignore">

{@title}

diff --git a/lib/plausible_web/live/dashboard/pages.ex b/lib/plausible_web/live/dashboard/pages.ex index 20a89a3fa5a4..ef3606f98ddd 100644 --- a/lib/plausible_web/live/dashboard/pages.ex +++ b/lib/plausible_web/live/dashboard/pages.ex @@ -101,9 +101,15 @@ defmodule PlausibleWeb.Live.Dashboard.Pages do def render(assigns) do assigns = assign(assigns, :external_link_fn, &external_link/1) + ~H"""
- + <:tabs> Date: Wed, 17 Dec 2025 11:30:07 +0100 Subject: [PATCH 07/20] Fix Phoenix/LV spinner opacity --- lib/plausible_web/components/generic.ex | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/plausible_web/components/generic.ex b/lib/plausible_web/components/generic.ex index 696195bdcaf6..c4407ba58561 100644 --- a/lib/plausible_web/components/generic.ex +++ b/lib/plausible_web/components/generic.ex @@ -425,10 +425,10 @@ defmodule PlausibleWeb.Components.Generic do viewBox="0 0 24 24" {@rest} > - + From 57756dbf0f2cb7ad22c97b8177df75c14b3fdafd Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Thu, 18 Dec 2025 12:54:59 +0100 Subject: [PATCH 08/20] Prevent repeat tab opening --- assets/js/liveview/dashboard_tabs.js | 13 +++++++------ lib/plausible_web/live/components/dashboard/tile.ex | 9 ++++++--- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/assets/js/liveview/dashboard_tabs.js b/assets/js/liveview/dashboard_tabs.js index 0f645a7f0134..7c33fdcc4f2a 100644 --- a/assets/js/liveview/dashboard_tabs.js +++ b/assets/js/liveview/dashboard_tabs.js @@ -18,22 +18,23 @@ export default buildHook({ const button = e.target.closest('button') const tab = button && button.dataset.tab - if (tab) { + if (tab && !button.dataset.active) { const label = button.dataset.label const storageKey = button.dataset.storageKey const activeClasses = button.dataset.activeClasses const inactiveClasses = button.dataset.inactiveClasses - const title = this.el - .closest('[data-tile]') - .querySelector('[data-title]') + const tile = this.el.closest('[data-tile') + const title = tile.querySelector('[data-title]') title.innerText = label - this.el.querySelectorAll(`button[data-tab] span`).forEach((s) => { - s.className = inactiveClasses + this.el.querySelectorAll(`button[data-tab]`).forEach((b) => { + b.querySelector('span').className = inactiveClasses + b.dataset.active = '' }) button.querySelector('span').className = activeClasses + button.dataset.active = 'true' if (storageKey) { localStorage.setItem(`${storageKey}__${domain}`, tab) diff --git a/lib/plausible_web/live/components/dashboard/tile.ex b/lib/plausible_web/live/components/dashboard/tile.ex index 0e21724b1d99..ec5c5a1a5673 100644 --- a/lib/plausible_web/live/components/dashboard/tile.ex +++ b/lib/plausible_web/live/components/dashboard/tile.ex @@ -47,10 +47,12 @@ defmodule PlausibleWeb.Components.Dashboard.Tile do def tab(assigns) do assigns = - assign(assigns, + assign( + assigns, active_classes: "text-indigo-600 dark:text-indigo-500 font-bold underline decoration-2 decoration-indigo-600 dark:decoration-indigo-500", - inactive_classes: "hover:text-indigo-700 dark:hover:text-indigo-400 cursor-pointer" + inactive_classes: "hover:text-indigo-700 dark:hover:text-indigo-400 cursor-pointer", + data_attrs: if(assigns.value == assigns.active, do: %{"data-active": "true"}, else: %{}) ) ~H""" @@ -61,9 +63,10 @@ defmodule PlausibleWeb.Components.Dashboard.Tile do data-storage-key="pageTab" data-active-classes={@active_classes} data-inactive-classes={@inactive_classes} - phx-click="set-tab" + phx-click={if(@value != @active, do: "set-tab")} phx-value-tab={@value} phx-target={@target} + {@data_attrs} > {@label} From 9da6d14d02b41e3fd7f8fdeefa88ded817205cce Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Thu, 18 Dec 2025 20:43:06 +0100 Subject: [PATCH 09/20] Implement optimistic load state for tile on tab switch --- .../live/components/dashboard/report_list.ex | 99 +++++++++++-------- .../live/components/dashboard/tile.ex | 22 +++-- lib/plausible_web/live/dashboard/pages.ex | 26 +++-- 3 files changed, 91 insertions(+), 56 deletions(-) diff --git a/lib/plausible_web/live/components/dashboard/report_list.ex b/lib/plausible_web/live/components/dashboard/report_list.ex index e3c53846a5ea..d3fa033cbce0 100644 --- a/lib/plausible_web/live/components/dashboard/report_list.ex +++ b/lib/plausible_web/live/components/dashboard/report_list.ex @@ -15,63 +15,84 @@ defmodule PlausibleWeb.Components.Dashboard.ReportList do @data_container_height (@row_height + @row_gap_height) * (@max_items - 1) + @row_height @col_min_width 70 - def report(assigns) do - max_value = - assigns.results - |> Enum.map(& &1.visitors) - |> Enum.max() + def height, do: @min_height + def report(assigns) do assigns = assign(assigns, - max_value: max_value, max_items: @max_items, min_height: @min_height, row_height: @row_height, row_gap_height: @row_gap_height, data_container_height: @data_container_height, - col_min_width: @col_min_width, - empty?: Enum.empty?(assigns.results) + col_min_width: @col_min_width ) - ~H""" - <.no_data :if={@empty?} min_height={@min_height} /> -
-
- <.report_header key_label={@key_label} metrics={@metrics} col_min_width={@col_min_width} /> -
+ if assigns.results.loading || !assigns.results.ok? do + ~H""" + """ + else + results = assigns.results.result + metrics = assigns.metrics.result + meta = assigns.meta.result + skip_imported_reason = assigns.skip_imported_reason.result + + max_value = + results + |> Enum.map(& &1.visitors) + |> Enum.max() + + assigns = + assign(assigns, + max_value: max_value, + results: results, + metrics: metrics, + meta: meta, + skip_imported_reason: skip_imported_reason, + empty?: Enum.empty?(results) + ) + + ~H""" + <.no_data :if={@empty?} min_height={@min_height} /> + +
+
+ <.report_header key_label={@key_label} metrics={@metrics} col_min_width={@col_min_width} /> +
-
- <.report_row - :for={item <- @results} - link_fn={assigns[:external_link_fn]} - item={item} - metrics={@metrics} - bar_value={item.visitors} - bar_max_value={@max_value} - site={@site} - params={@params} - filter_dimension={@filter_dimension} - row_height={@row_height} - row_gap_height={@row_gap_height} - col_min_width={@col_min_width} - /> -
+
+ <.report_row + :for={item <- @results} + link_fn={assigns[:external_link_fn]} + item={item} + metrics={@metrics} + bar_value={item.visitors} + bar_max_value={@max_value} + site={@site} + params={@params} + filter_dimension={@filter_dimension} + row_height={@row_height} + row_gap_height={@row_gap_height} + col_min_width={@col_min_width} + /> +
-
- <.details_link - site={@site} - params={@params} - path="/pages" - /> +
+ <.details_link + site={@site} + params={@params} + path="/pages" + /> +
-
- """ + """ + end end defp no_data(assigns) do ~H"""
diff --git a/lib/plausible_web/live/components/dashboard/tile.ex b/lib/plausible_web/live/components/dashboard/tile.ex index ec5c5a1a5673..6d6e3a68cfa8 100644 --- a/lib/plausible_web/live/components/dashboard/tile.ex +++ b/lib/plausible_web/live/components/dashboard/tile.ex @@ -8,9 +8,7 @@ defmodule PlausibleWeb.Components.Dashboard.Tile do attr :id, :string, required: true attr :class, :string, default: "" attr :title, :string, required: true - # Optimistic rendering requires preventing LV patching of - # title and tabs. The update of those is handled by `tab` - # widget hook. + attr :height, :integer, required: true attr :connected?, :boolean, required: true slot :tabs @@ -18,7 +16,7 @@ defmodule PlausibleWeb.Components.Dashboard.Tile do def tile(assigns) do ~H""" -
+
"-title"} class="flex gap-x-1" phx-update="ignore">

{@title}

@@ -27,7 +25,6 @@ defmodule PlausibleWeb.Components.Dashboard.Tile do
"-tabs"} - phx-update="ignore" phx-hook="DashboardTabs" class="flex text-xs font-medium text-gray-500 dark:text-gray-400 space-x-2 items-baseline" > @@ -35,7 +32,18 @@ defmodule PlausibleWeb.Components.Dashboard.Tile do
- {render_slot(@inner_block)} + + +
+ {render_slot(@inner_block)} +
""" end @@ -57,7 +65,7 @@ defmodule PlausibleWeb.Components.Dashboard.Tile do ~H""" From d25c4d71947bf3cc43792288376b50e81bb32cf0 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Mon, 22 Dec 2025 10:56:05 +0100 Subject: [PATCH 12/20] Use native hook API for setting data attributes --- assets/js/liveview/dashboard_tabs.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/assets/js/liveview/dashboard_tabs.js b/assets/js/liveview/dashboard_tabs.js index 53d63a4270cd..006cf52c3838 100644 --- a/assets/js/liveview/dashboard_tabs.js +++ b/assets/js/liveview/dashboard_tabs.js @@ -29,10 +29,14 @@ export default buildHook({ title.innerText = label this.el.querySelectorAll(`button[data-tab] span`).forEach((s) => { - s.dataset.active = 'false' + this.js().setAttribute(s, 'data-active', 'false') }) - button.querySelector('span').dataset.active = 'true' + this.js().setAttribute( + button.querySelector('span'), + 'data-active', + 'true' + ) if (storageKey) { localStorage.setItem(`${storageKey}__${domain}`, tab) From be6943ba732cb0426fca0cdd11512cbaf2a42596 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Mon, 22 Dec 2025 12:58:23 +0100 Subject: [PATCH 13/20] Drop default adjustments from DashboardQueryParser --- lib/plausible/stats/dashboard_query_parser.ex | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/lib/plausible/stats/dashboard_query_parser.ex b/lib/plausible/stats/dashboard_query_parser.ex index fe3387b9f224..75875287f736 100644 --- a/lib/plausible/stats/dashboard_query_parser.ex +++ b/lib/plausible/stats/dashboard_query_parser.ex @@ -14,7 +14,7 @@ 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: false, + time_labels: true, total_rows: false, trim_relative_date_range: true, compare: nil, @@ -43,8 +43,6 @@ defmodule Plausible.Stats.DashboardQueryParser do {:ok, ParsedQueryParams.new!(%{ - metrics: [], - dimensions: [], input_date_range: parse_input_date_range(params_map), relative_date: relative_date, filters: filters, From 813adea3410a438a2dc62b84d4daaec8e5e3d28f Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Mon, 22 Dec 2025 15:27:27 +0100 Subject: [PATCH 14/20] Handle filters with ParsedQueryParams API and fix param defaults --- .../live/components/dashboard/base.ex | 15 ++------------- lib/plausible_web/live/dashboard.ex | 6 +++--- 2 files changed, 5 insertions(+), 16 deletions(-) diff --git a/lib/plausible_web/live/components/dashboard/base.ex b/lib/plausible_web/live/components/dashboard/base.ex index 77088e53cd77..e57201ff4d82 100644 --- a/lib/plausible_web/live/components/dashboard/base.ex +++ b/lib/plausible_web/live/components/dashboard/base.ex @@ -6,6 +6,7 @@ defmodule PlausibleWeb.Components.Dashboard.Base do use PlausibleWeb, :component alias Plausible.Stats.DashboardQuerySerializer + alias Plausible.Stats.ParsedQueryParams attr :site, Plausible.Site, required: true attr :params, :map, required: true @@ -49,7 +50,7 @@ defmodule PlausibleWeb.Components.Dashboard.Base do slot :inner_block, required: true def filter_link(assigns) do - params = replace_filter(assigns.params, assigns.filter) + params = ParsedQueryParams.add_or_replace_filter(assigns.params, assigns.filter) assigns = assign(assigns, :params, params) @@ -83,16 +84,4 @@ defmodule PlausibleWeb.Components.Dashboard.Base do
""" end - - defp replace_filter(params, filter) do - [:is, dimension, _values] = filter - - filters = - Enum.reject(params.filters, fn - {:is, ^dimension, _} -> true - _ -> false - end) - - %{params | filters: [filter | filters]} - end end diff --git a/lib/plausible_web/live/dashboard.ex b/lib/plausible_web/live/dashboard.ex index 9f9bc012add3..88dbeffddb83 100644 --- a/lib/plausible_web/live/dashboard.ex +++ b/lib/plausible_web/live/dashboard.ex @@ -11,8 +11,7 @@ defmodule PlausibleWeb.Live.Dashboard do alias Plausible.Teams @default_prefs %{ - "period" => "28d", - "match_day_of_week" => true + "period" => "28d" } @spec enabled?(Plausible.Site.t() | nil) :: boolean() @@ -23,7 +22,7 @@ defmodule PlausibleWeb.Live.Dashboard do end def mount(_params, %{"domain" => domain, "url" => url}, socket) do - # TODO: make it more permissive of invalid values in search params and stored values + # NOTE: implement a dedicated, permissive params fallback. user_prefs = Map.merge(@default_prefs, get_connect_params(socket)["user_prefs"] || %{}) # As domain is passed via session, the associated site has already passed @@ -52,6 +51,7 @@ defmodule PlausibleWeb.Live.Dashboard do uri = URI.new!(url) path = uri.path |> String.split("/") |> Enum.drop(2) {:ok, params} = DashboardQueryParser.parse(uri.query || "", socket.assigns.user_prefs) + params = %{params | include: struct!(params.include, time_labels: false)} {:ok, query} = QueryBuilder.build(socket.assigns.site, params, %{}) socket = From a00d908fb738d123368c53b4be7b2043a6b2ab04 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Mon, 22 Dec 2025 21:08:06 +0100 Subject: [PATCH 15/20] Fix fetching max visitors for empty report list --- lib/plausible_web/live/components/dashboard/report_list.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/plausible_web/live/components/dashboard/report_list.ex b/lib/plausible_web/live/components/dashboard/report_list.ex index e42f51711ea7..649c177a71e6 100644 --- a/lib/plausible_web/live/components/dashboard/report_list.ex +++ b/lib/plausible_web/live/components/dashboard/report_list.ex @@ -40,7 +40,7 @@ defmodule PlausibleWeb.Components.Dashboard.ReportList do max_value = results |> Enum.map(& &1.visitors) - |> Enum.max() + |> Enum.max(&>=/2, fn -> 0 end) assigns = assign(assigns, From 2676cd4bee48ae615b3037229ca5ee9a2752a3cc Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Mon, 22 Dec 2025 21:27:53 +0100 Subject: [PATCH 16/20] Handle poorly formed URIs sent to dashboard gracefully --- lib/plausible_web/live/dashboard.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/plausible_web/live/dashboard.ex b/lib/plausible_web/live/dashboard.ex index 88dbeffddb83..f72981221e44 100644 --- a/lib/plausible_web/live/dashboard.ex +++ b/lib/plausible_web/live/dashboard.ex @@ -48,7 +48,7 @@ defmodule PlausibleWeb.Live.Dashboard do end def handle_params_internal(_params, url, socket) do - uri = URI.new!(url) + uri = URI.parse(url) path = uri.path |> String.split("/") |> Enum.drop(2) {:ok, params} = DashboardQueryParser.parse(uri.query || "", socket.assigns.user_prefs) params = %{params | include: struct!(params.include, time_labels: false)} From 9161c44a3ea534930aebc56c527d7796002116b1 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Tue, 23 Dec 2025 00:05:46 +0100 Subject: [PATCH 17/20] Defer query building until all parameters are known --- lib/plausible/stats/parsed_query_params.ex | 8 +++++ lib/plausible_web/live/dashboard.ex | 7 +--- lib/plausible_web/live/dashboard/pages.ex | 39 ++++++++++++++-------- 3 files changed, 35 insertions(+), 19 deletions(-) diff --git a/lib/plausible/stats/parsed_query_params.ex b/lib/plausible/stats/parsed_query_params.ex index 2d940f087370..0c473db7a83d 100644 --- a/lib/plausible/stats/parsed_query_params.ex +++ b/lib/plausible/stats/parsed_query_params.ex @@ -19,6 +19,14 @@ defmodule Plausible.Stats.ParsedQueryParams do struct!(__MODULE__, Map.to_list(params)) end + def set(params, keywords) do + struct!(params, keywords) + end + + def set_include(params, key, value) do + struct!(params, include: struct!(params.include, [{key, value}])) + end + @props_prefix "event:props:" def add_or_replace_filter(%__MODULE__{filters: filters} = parsed_query_params, new_filter) do diff --git a/lib/plausible_web/live/dashboard.ex b/lib/plausible_web/live/dashboard.ex index f72981221e44..bc2a6d425ecf 100644 --- a/lib/plausible_web/live/dashboard.ex +++ b/lib/plausible_web/live/dashboard.ex @@ -7,7 +7,6 @@ defmodule PlausibleWeb.Live.Dashboard do alias Plausible.Repo alias Plausible.Stats.DashboardQueryParser - alias Plausible.Stats.QueryBuilder alias Plausible.Teams @default_prefs %{ @@ -51,14 +50,11 @@ defmodule PlausibleWeb.Live.Dashboard do uri = URI.parse(url) path = uri.path |> String.split("/") |> Enum.drop(2) {:ok, params} = DashboardQueryParser.parse(uri.query || "", socket.assigns.user_prefs) - params = %{params | include: struct!(params.include, time_labels: false)} - {:ok, query} = QueryBuilder.build(socket.assigns.site, params, %{}) socket = assign(socket, path: path, - params: params, - query: query + params: params ) {:noreply, socket} @@ -75,7 +71,6 @@ defmodule PlausibleWeb.Live.Dashboard do user_prefs={@user_prefs} connected?={@connected?} params={@params} - query={@query} />
diff --git a/lib/plausible_web/live/dashboard/pages.ex b/lib/plausible_web/live/dashboard/pages.ex index e08aba5b7c16..ffb9c89e7e2f 100644 --- a/lib/plausible_web/live/dashboard/pages.ex +++ b/lib/plausible_web/live/dashboard/pages.ex @@ -10,6 +10,8 @@ defmodule PlausibleWeb.Live.Dashboard.Pages do alias Plausible.Stats alias Plausible.Stats.Filters + alias Plausible.Stats.ParsedQueryParams + alias Plausible.Stats.QueryBuilder @tabs [ {"pages", "Top Pages"}, @@ -87,7 +89,6 @@ defmodule PlausibleWeb.Live.Dashboard.Pages do assign(socket, site: assigns.site, params: assigns.params, - query: assigns.query, tabs: @tabs, key_labels: @key_labels, filter_dimensions: @filter_dimensions, @@ -155,11 +156,11 @@ defmodule PlausibleWeb.Live.Dashboard.Pages do end defp load_metrics(socket) do - %{active_tab: active_tab, site: site, query: query} = socket.assigns + %{active_tab: active_tab, site: site, params: params} = socket.assigns assign_async(socket, [:metrics, :results, :meta, :skip_imported_reason], fn -> - %{results: pages, meta: meta, metrics: metrics} = - metrics_for_tab(active_tab, site, query) + %{results: pages, meta: meta, query: query, metrics: metrics} = + metrics_for_tab(active_tab, site, params) {:ok, %{ @@ -171,9 +172,13 @@ defmodule PlausibleWeb.Live.Dashboard.Pages do end) end - defp metrics_for_tab("pages", site, query) do - query = struct!(query, dimensions: ["event:page"]) + defp metrics_for_tab("pages", site, params) do + params = + params + |> ParsedQueryParams.set(dimensions: ["event:page"]) + |> ParsedQueryParams.set_include(:time_labels, false) + {:ok, query} = QueryBuilder.build(site, params, %{}) metrics = breakdown_metrics(query) %{results: results, meta: meta} = Stats.breakdown(site, query, metrics, @pagination_params) @@ -182,12 +187,16 @@ defmodule PlausibleWeb.Live.Dashboard.Pages do results |> transform_keys(%{page: :name}) - %{results: pages, meta: meta, metrics: metrics} + %{query: query, results: pages, meta: meta, metrics: metrics} end - defp metrics_for_tab("entry-pages", site, query) do - query = struct!(query, dimensions: ["visit:entry_page"]) + defp metrics_for_tab("entry-pages", site, params) do + params = + params + |> ParsedQueryParams.set(dimensions: ["visit:entry_page"]) + |> ParsedQueryParams.set_include(:time_labels, false) + {:ok, query} = QueryBuilder.build(site, params, %{}) metrics = breakdown_metrics(query) %{results: results, meta: meta} = Stats.breakdown(site, query, metrics, @pagination_params) @@ -196,12 +205,16 @@ defmodule PlausibleWeb.Live.Dashboard.Pages do results |> transform_keys(%{entry_page: :name}) - %{results: pages, meta: meta, metrics: metrics} + %{query: query, results: pages, meta: meta, metrics: metrics} end - defp metrics_for_tab("exit-pages", site, query) do - query = struct!(query, dimensions: ["visit:exit_page"]) + defp metrics_for_tab("exit-pages", site, params) do + params = + params + |> ParsedQueryParams.set(dimensions: ["visit:exit_page"]) + |> ParsedQueryParams.set_include(:time_labels, false) + {:ok, query} = QueryBuilder.build(site, params, %{}) metrics = breakdown_metrics(query) %{results: results, meta: meta} = Stats.breakdown(site, query, metrics, @pagination_params) @@ -210,7 +223,7 @@ defmodule PlausibleWeb.Live.Dashboard.Pages do results |> transform_keys(%{exit_page: :name}) - %{results: pages, meta: meta, metrics: metrics} + %{query: query, results: pages, meta: meta, metrics: metrics} end defp breakdown_metrics(query) do From 6fa68ce28d66e791093f4b122a01271300a5a5f5 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Tue, 23 Dec 2025 11:27:50 +0100 Subject: [PATCH 18/20] Get rid of whitespace --- lib/plausible_web/live/dashboard.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/plausible_web/live/dashboard.ex b/lib/plausible_web/live/dashboard.ex index bc2a6d425ecf..5a7cd197d8b4 100644 --- a/lib/plausible_web/live/dashboard.ex +++ b/lib/plausible_web/live/dashboard.ex @@ -21,7 +21,7 @@ defmodule PlausibleWeb.Live.Dashboard do end def mount(_params, %{"domain" => domain, "url" => url}, socket) do - # NOTE: implement a dedicated, permissive params fallback. + # NOTE: implement a dedicated, permissive params fallback. user_prefs = Map.merge(@default_prefs, get_connect_params(socket)["user_prefs"] || %{}) # As domain is passed via session, the associated site has already passed From ff56a2df852e41736a4608307e16513c2857a982 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Tue, 23 Dec 2025 13:04:16 +0100 Subject: [PATCH 19/20] Drop unnecessary phx-update=ignore from tabs component --- lib/plausible_web/live/components/dashboard/tile.ex | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/plausible_web/live/components/dashboard/tile.ex b/lib/plausible_web/live/components/dashboard/tile.ex index 599be59425f4..a8f44b885d99 100644 --- a/lib/plausible_web/live/components/dashboard/tile.ex +++ b/lib/plausible_web/live/components/dashboard/tile.ex @@ -25,7 +25,6 @@ defmodule PlausibleWeb.Components.Dashboard.Tile do
"-tabs"} - phx-update="ignore" phx-hook="DashboardTabs" class="tile-tabs flex text-xs font-medium text-gray-500 dark:text-gray-400 space-x-2 items-baseline" > From 32b833600fadd5c9c3265c4ed41577863a8af22d Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Tue, 23 Dec 2025 13:41:16 +0100 Subject: [PATCH 20/20] Implement loading state for navigation events --- assets/js/liveview/dashboard_root.js | 26 ++++++++++++++----- .../live/components/dashboard/tile.ex | 4 +-- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/assets/js/liveview/dashboard_root.js b/assets/js/liveview/dashboard_root.js index 42af690f103c..32a9ab2258a7 100644 --- a/assets/js/liveview/dashboard_root.js +++ b/assets/js/liveview/dashboard_root.js @@ -6,10 +6,26 @@ import { buildHook } from './hook_builder' +function navigateWithLoader(url) { + this.portalTargets.map((target) => { + this.js().addClass(document.querySelector(target), 'phx-navigation-loading') + + this.pushEvent('handle_dashboard_params', { url: url }, () => { + this.js().removeClass( + document.querySelector(target), + 'phx-navigation-loading' + ) + }) + }) +} + export default buildHook({ initialize() { this.url = window.location.href + const portals = document.querySelectorAll('[data-phx-portal]') + this.portalTargets = Array.from(portals, (p) => p.dataset.phxPortal) + this.addListener('click', document.body, (e) => { const type = e.target.dataset.type || null @@ -26,7 +42,7 @@ export default buildHook({ }) ) - this.pushEvent('handle_dashboard_params', { url: this.url }) + navigateWithLoader.bind(this)(this.url) e.preventDefault() } @@ -35,9 +51,7 @@ export default buildHook({ // Browser back and forward navigation triggers that event. this.addListener('popstate', window, () => { if (this.url !== window.location.href) { - this.pushEvent('handle_dashboard_params', { - url: window.location.href - }) + navigateWithLoader.bind(this)(window.location.href) } }) @@ -48,9 +62,7 @@ export default buildHook({ typeof e.detail.search === 'string' && this.url !== window.location.href ) { - this.pushEvent('handle_dashboard_params', { - url: window.location.href - }) + navigateWithLoader.bind(this)(window.location.href) } }) } diff --git a/lib/plausible_web/live/components/dashboard/tile.ex b/lib/plausible_web/live/components/dashboard/tile.ex index a8f44b885d99..7561a5abc5dd 100644 --- a/lib/plausible_web/live/components/dashboard/tile.ex +++ b/lib/plausible_web/live/components/dashboard/tile.ex @@ -33,7 +33,7 @@ defmodule PlausibleWeb.Components.Dashboard.Tile do
-
+
{render_slot(@inner_block)}