From 95fa2c360766b413583d3c4a0eacbe63724130f5 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Thu, 19 Mar 2026 14:23:17 +0000 Subject: [PATCH 01/40] transform pages_test --- .../api/stats_controller/pages_test.exs | 1946 +++++++---------- 1 file changed, 763 insertions(+), 1183 deletions(-) diff --git a/test/plausible_web/controllers/api/stats_controller/pages_test.exs b/test/plausible_web/controllers/api/stats_controller/pages_test.exs index 5220a1b93915..7120a2f80a41 100644 --- a/test/plausible_web/controllers/api/stats_controller/pages_test.exs +++ b/test/plausible_web/controllers/api/stats_controller/pages_test.exs @@ -3,6 +3,34 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do @user_id Enum.random(1000..9999) + @default_metrics ["visitors", "percentage"] + @detailed_metrics [ + "visitors", + "pageviews", + "bounce_rate", + "time_on_page", + "scroll_depth", + "percentage" + ] + @goal_filter_metrics ["visitors", "group_conversion_rate", "total_visitors"] + + defp query_pages(conn, site, opts) do + params = %{ + "dimensions" => Keyword.get(opts, :dimensions, ["event:page"]), + "date_range" => Keyword.get(opts, :date_range, "all"), + "relative_date" => Keyword.get(opts, :relative_date, nil), + "filters" => Keyword.get(opts, :filters, []), + "metrics" => Keyword.get(opts, :metrics, @default_metrics), + "include" => Keyword.get(opts, :include, nil), + "pagination" => Keyword.get(opts, :pagination, nil), + "order_by" => Keyword.get(opts, :order_by, nil) + } + + conn + |> post("/api/stats/#{site.domain}/query", params) + |> json_response(200) + end + describe "GET /api/stats/:domain/pages" do setup [ :create_user, @@ -21,16 +49,16 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do build(:pageview, pathname: "/contact") ]) - conn = get(conn, "/api/stats/#{site.domain}/pages?period=day") + response = query_pages(conn, site, date_range: "day") - assert json_response(conn, 200)["results"] == [ - %{"visitors" => 3, "name" => "/", "percentage" => 50.0}, - %{"visitors" => 2, "name" => "/register", "percentage" => 33.33}, - %{"visitors" => 1, "name" => "/contact", "percentage" => 16.67} + assert response["results"] == [ + %{"dimensions" => ["/"], "metrics" => [3, 50.0]}, + %{"dimensions" => ["/register"], "metrics" => [2, 33.33]}, + %{"dimensions" => ["/contact"], "metrics" => [1, 16.67]} ] end - test "returns top pages by visitors by hostname", %{conn: conn1, site: site} do + test "returns top pages by visitors by hostname", %{conn: conn, site: site} do populate_stats(site, [ build(:pageview, pathname: "/", hostname: "a.example.com"), build(:pageview, pathname: "/", hostname: "b.example.com"), @@ -42,22 +70,29 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do build(:pageview, pathname: "/contact", hostname: "e.example.com") ]) - filters = Jason.encode!([[:contains, "event:hostname", [".example.com"]]]) - conn = get(conn1, "/api/stats/#{site.domain}/pages?period=day&filters=#{filters}") + response = + query_pages(conn, site, + date_range: "day", + filters: [["contains", "event:hostname", [".example.com"]]], + order_by: [["visitors", "desc"], ["event:page", "asc"]] + ) - assert json_response(conn, 200)["results"] == [ - %{"visitors" => 3, "name" => "/", "percentage" => 50.0}, - %{"visitors" => 2, "name" => "/register", "percentage" => 33.33}, - %{"visitors" => 1, "name" => "/contact", "percentage" => 16.67}, - %{"visitors" => 1, "name" => "/landing", "percentage" => 16.67} + assert response["results"] == [ + %{"dimensions" => ["/"], "metrics" => [3, 50.0]}, + %{"dimensions" => ["/register"], "metrics" => [2, 33.33]}, + %{"dimensions" => ["/contact"], "metrics" => [1, 16.67]}, + %{"dimensions" => ["/landing"], "metrics" => [1, 16.67]} ] - filters = Jason.encode!([[:is, "event:hostname", ["d.example.com"]]]) - conn = get(conn1, "/api/stats/#{site.domain}/pages?period=day&filters=#{filters}") + response = + query_pages(conn, site, + date_range: "day", + filters: [["is", "event:hostname", ["d.example.com"]]] + ) - assert json_response(conn, 200)["results"] == [ - %{"visitors" => 2, "name" => "/register", "percentage" => 66.67}, - %{"visitors" => 1, "name" => "/", "percentage" => 33.33} + assert response["results"] == [ + %{"dimensions" => ["/register"], "metrics" => [2, 66.67]}, + %{"dimensions" => ["/"], "metrics" => [1, 33.33]} ] end @@ -76,11 +111,14 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do build(:pageview, user_id: 123, pathname: "/") ]) - filters = Jason.encode!([[:is, "event:props:author", ["John Doe"]]]) - conn = get(conn, "/api/stats/#{site.domain}/pages?period=day&filters=#{filters}") + response = + query_pages(conn, site, + date_range: "day", + filters: [["is", "event:props:author", ["John Doe"]]] + ) - assert json_response(conn, 200)["results"] == [ - %{"visitors" => 1, "name" => "/blog/john-1", "percentage" => 100.0} + assert response["results"] == [ + %{"dimensions" => ["/blog/john-1"], "metrics" => [1, 100.0]} ] end @@ -102,12 +140,15 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do build(:pageview, pathname: "/") ]) - filters = Jason.encode!([[:is_not, "event:props:author", ["John Doe"]]]) - conn = get(conn, "/api/stats/#{site.domain}/pages?period=day&filters=#{filters}") + response = + query_pages(conn, site, + date_range: "day", + filters: [["is_not", "event:props:author", ["John Doe"]]] + ) - assert json_response(conn, 200)["results"] == [ - %{"visitors" => 1, "name" => "/", "percentage" => 50.0}, - %{"visitors" => 1, "name" => "/blog/other-post", "percentage" => 50.0} + assert response["results"] == [ + %{"dimensions" => ["/"], "metrics" => [1, 50.0]}, + %{"dimensions" => ["/blog/other-post"], "metrics" => [1, 50.0]} ] end @@ -139,12 +180,16 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do build(:pageview, pathname: "/5") ]) - filters = Jason.encode!([[:contains, "event:props:prop", ["bar"]]]) - conn = get(conn, "/api/stats/#{site.domain}/pages?period=day&filters=#{filters}") + response = + query_pages(conn, site, + date_range: "day", + filters: [["contains", "event:props:prop", ["bar"]]], + order_by: [["visitors", "desc"], ["event:page", "asc"]] + ) - assert json_response(conn, 200)["results"] == [ - %{"visitors" => 1, "name" => "/1", "percentage" => 50.0}, - %{"visitors" => 1, "name" => "/2", "percentage" => 50.0} + assert response["results"] == [ + %{"dimensions" => ["/1"], "metrics" => [1, 50.0]}, + %{"dimensions" => ["/2"], "metrics" => [1, 50.0]} ] end @@ -181,13 +226,17 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do ) ]) - filters = Jason.encode!([[:contains, "event:props:prop", ["bar", "nea"]]]) - conn = get(conn, "/api/stats/#{site.domain}/pages?period=day&filters=#{filters}") + response = + query_pages(conn, site, + date_range: "day", + filters: [["contains", "event:props:prop", ["bar", "nea"]]], + order_by: [["visitors", "desc"], ["event:page", "asc"]] + ) - assert json_response(conn, 200)["results"] == [ - %{"visitors" => 1, "name" => "/1", "percentage" => 33.33}, - %{"visitors" => 1, "name" => "/2", "percentage" => 33.33}, - %{"visitors" => 1, "name" => "/6", "percentage" => 33.33} + assert response["results"] == [ + %{"dimensions" => ["/1"], "metrics" => [1, 33.33]}, + %{"dimensions" => ["/2"], "metrics" => [1, 33.33]}, + %{"dimensions" => ["/6"], "metrics" => [1, 33.33]} ] end @@ -219,16 +268,17 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do build(:pageview, pathname: "/5") ]) - filters = - Jason.encode!([ - [:is, "event:props:prop", ["bar"]], - [:is, "event:props:number", ["1"]] - ]) - - conn = get(conn, "/api/stats/#{site.domain}/pages?period=day&filters=#{filters}") + response = + query_pages(conn, site, + date_range: "day", + filters: [ + ["is", "event:props:prop", ["bar"]], + ["is", "event:props:number", ["1"]] + ] + ) - assert json_response(conn, 200)["results"] == [ - %{"visitors" => 1, "name" => "/1", "percentage" => 100.0} + assert response["results"] == [ + %{"dimensions" => ["/1"], "metrics" => [1, 100.0]} ] end @@ -304,33 +354,16 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do ) ]) - filters = Jason.encode!([[:is, "event:props:author", ["John Doe"]]]) - - conn = - get( - conn, - "/api/stats/#{site.domain}/pages?period=day&date=2021-01-01&filters=#{filters}&detailed=true" + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + filters: [["is", "event:props:author", ["John Doe"]]], + metrics: @detailed_metrics ) - assert json_response(conn, 200)["results"] == [ - %{ - "name" => "/blog/john-2", - "visitors" => 2, - "pageviews" => 2, - "bounce_rate" => 0, - "time_on_page" => 315, - "scroll_depth" => 0, - "percentage" => 100.0 - }, - %{ - "name" => "/blog/john-1", - "visitors" => 1, - "pageviews" => 1, - "bounce_rate" => 0, - "time_on_page" => 60, - "scroll_depth" => 0, - "percentage" => 50.0 - } + assert response["results"] == [ + %{"dimensions" => ["/blog/john-2"], "metrics" => [2, 2, 0, 315, 0, 100.0]}, + %{"dimensions" => ["/blog/john-1"], "metrics" => [1, 1, 0, 60, 0, 50.0]} ] end @@ -406,33 +439,16 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do ) ]) - filters = Jason.encode!([[:is_not, "event:props:author", ["John Doe"]]]) - - conn = - get( - conn, - "/api/stats/#{site.domain}/pages?period=day&date=2021-01-01&filters=#{filters}&detailed=true" + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + filters: [["is_not", "event:props:author", ["John Doe"]]], + metrics: @detailed_metrics ) - assert json_response(conn, 200)["results"] == [ - %{ - "name" => "/blog", - "visitors" => 2, - "pageviews" => 2, - "bounce_rate" => 0, - "time_on_page" => 120, - "scroll_depth" => 0, - "percentage" => 100.0 - }, - %{ - "name" => "/blog/other-post", - "visitors" => 1, - "pageviews" => 1, - "bounce_rate" => 0, - "time_on_page" => 30, - "scroll_depth" => 0, - "percentage" => 50.0 - } + assert response["results"] == [ + %{"dimensions" => ["/blog"], "metrics" => [2, 2, 0, 120, 0, 100.0]}, + %{"dimensions" => ["/blog/other-post"], "metrics" => [1, 1, 0, 30, 0, 50.0]} ] end @@ -489,33 +505,16 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do ) ]) - filters = Jason.encode!([[:is, "event:props:author", ["(none)"]]]) - - conn = - get( - conn, - "/api/stats/#{site.domain}/pages?period=day&date=2021-01-01&filters=#{filters}&detailed=true" + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + filters: [["is", "event:props:author", ["(none)"]]], + metrics: @detailed_metrics ) - assert json_response(conn, 200)["results"] == [ - %{ - "name" => "/blog", - "visitors" => 2, - "pageviews" => 2, - "bounce_rate" => 50, - "time_on_page" => 45, - "scroll_depth" => 0, - "percentage" => 100.0 - }, - %{ - "name" => "/blog/other-post", - "visitors" => 1, - "pageviews" => 1, - "bounce_rate" => 0, - "time_on_page" => 30, - "scroll_depth" => 0, - "percentage" => 50.0 - } + assert response["results"] == [ + %{"dimensions" => ["/blog"], "metrics" => [2, 2, 50, 45, 0, 100.0]}, + %{"dimensions" => ["/blog/other-post"], "metrics" => [1, 1, 0, 30, 0, 50.0]} ] end @@ -580,33 +579,16 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do ) ]) - filters = Jason.encode!([[:is_not, "event:props:author", ["(none)"]]]) - - conn = - get( - conn, - "/api/stats/#{site.domain}/pages?period=day&date=2021-01-01&filters=#{filters}&detailed=true" + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + filters: [["is_not", "event:props:author", ["(none)"]]], + metrics: @detailed_metrics ) - assert json_response(conn, 200)["results"] == [ - %{ - "name" => "/blog/other-post", - "visitors" => 2, - "pageviews" => 2, - "bounce_rate" => 100, - "time_on_page" => 30, - "scroll_depth" => 0, - "percentage" => 100.0 - }, - %{ - "name" => "/blog/john-1", - "visitors" => 1, - "pageviews" => 1, - "bounce_rate" => 0, - "time_on_page" => 60, - "scroll_depth" => 0, - "percentage" => 50.0 - } + assert response["results"] == [ + %{"dimensions" => ["/blog/other-post"], "metrics" => [2, 2, 100, 30, 0, 100.0]}, + %{"dimensions" => ["/blog/john-1"], "metrics" => [1, 1, 0, 60, 0, 50.0]} ] end @@ -645,17 +627,14 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do ) ]) - filters = Jason.encode!([[:is_not, "event:props:browser", ["Chrome", "Safari"]]]) - - conn = - get(conn, "/api/stats/#{site.domain}/pages?period=day&date=2021-01-01&filters=#{filters}") + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + filters: [["is_not", "event:props:browser", ["Chrome", "Safari"]]] + ) - assert json_response(conn, 200)["results"] == [ - %{ - "name" => "/firefox", - "visitors" => 2, - "percentage" => 100.0 - } + assert response["results"] == [ + %{"dimensions" => ["/firefox"], "metrics" => [2, 100.0]} ] assert json_response(conn, 200)["meta"] == %{"date_range_label" => "1 Jan 2021"} @@ -688,17 +667,14 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do ) ]) - filters = Jason.encode!([[:is_not, "event:props:browser", ["Chrome", "(none)"]]]) - - conn = - get(conn, "/api/stats/#{site.domain}/pages?period=day&date=2021-01-01&filters=#{filters}") + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + filters: [["is_not", "event:props:browser", ["Chrome", "(none)"]]] + ) - assert json_response(conn, 200)["results"] == [ - %{ - "name" => "/safari", - "visitors" => 1, - "percentage" => 100.0 - } + assert response["results"] == [ + %{"dimensions" => ["/safari"], "metrics" => [1, 100.0]} ] end @@ -755,24 +731,15 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do ) ]) - filters = Jason.encode!([[:is, "event:page", ["/"]]]) - - conn = - get( - conn, - "/api/stats/#{site.domain}/pages?period=day&date=2021-01-01&filters=#{filters}&detailed=true" + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + filters: [["is", "event:page", ["/"]]], + metrics: @detailed_metrics ) - assert json_response(conn, 200)["results"] == [ - %{ - "name" => "/", - "visitors" => 2, - "pageviews" => 3, - "bounce_rate" => 50, - "time_on_page" => 90, - "scroll_depth" => 0, - "percentage" => 100.0 - } + assert response["results"] == [ + %{"dimensions" => ["/"], "metrics" => [2, 3, 50, 90, 0, 100.0]} ] end @@ -831,31 +798,16 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do ) ]) - conn = - get( - conn, - "/api/stats/#{site.domain}/pages?period=day&date=2020-01-01&detailed=true&order_by=#{Jason.encode!([["scroll_depth", "asc"]])}" + response = + query_pages(conn, site, + date_range: ["2020-01-01", "2020-01-01"], + metrics: @detailed_metrics, + order_by: [["scroll_depth", "asc"]] ) - assert json_response(conn, 200)["results"] == [ - %{ - "name" => "/another", - "visitors" => 2, - "pageviews" => 2, - "bounce_rate" => 0, - "time_on_page" => 60, - "scroll_depth" => 25, - "percentage" => 66.67 - }, - %{ - "name" => "/blog", - "visitors" => 3, - "pageviews" => 4, - "bounce_rate" => 33, - "time_on_page" => 80, - "scroll_depth" => 60, - "percentage" => 100.0 - } + assert response["results"] == [ + %{"dimensions" => ["/another"], "metrics" => [2, 2, 0, 60, 25, 66.67]}, + %{"dimensions" => ["/blog"], "metrics" => [3, 4, 33, 80, 60, 100.0]} ] end @@ -895,22 +847,16 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do build(:imported_visitors, date: ~D[2020-01-01]) ]) - conn = - get( - conn, - "/api/stats/#{site.domain}/pages?period=day&date=2020-01-01&detailed=true&with_imported=true&order_by=#{Jason.encode!([["scroll_depth", "desc"]])}" + response = + query_pages(conn, site, + date_range: ["2020-01-01", "2020-01-01"], + metrics: @detailed_metrics, + include: %{"imports" => true}, + order_by: [["scroll_depth", "desc"]] ) - assert json_response(conn, 200)["results"] == [ - %{ - "name" => "/blog", - "visitors" => 4, - "pageviews" => 4, - "bounce_rate" => 100, - "time_on_page" => 28, - "scroll_depth" => 50, - "percentage" => 100.0 - } + assert response["results"] == [ + %{"dimensions" => ["/blog"], "metrics" => [4, 4, 100, 28, 50, 100.0]} ] end @@ -972,40 +918,18 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do for(_ <- 1..24, do: build(:imported_visitors, date: ~D[2020-01-01])) ) - conn = - get( - conn, - "/api/stats/#{site.domain}/pages?period=day&date=2020-01-01&detailed=true&with_imported=true&order_by=#{Jason.encode!([["scroll_depth", "desc"]])}" + response = + query_pages(conn, site, + date_range: ["2020-01-01", "2020-01-01"], + metrics: @detailed_metrics, + include: %{"imports" => true}, + order_by: [["scroll_depth", "desc"]] ) - assert json_response(conn, 200)["results"] == [ - %{ - "name" => "/native-and-imported", - "visitors" => 5, - "pageviews" => 5, - "bounce_rate" => 0, - "time_on_page" => 48, - "scroll_depth" => 50, - "percentage" => 20.0 - }, - %{ - "name" => "/native-only", - "visitors" => 1, - "pageviews" => 1, - "bounce_rate" => 0, - "time_on_page" => 60, - "scroll_depth" => 40, - "percentage" => 4.0 - }, - %{ - "name" => "/imported-only", - "visitors" => 20, - "pageviews" => 30, - "bounce_rate" => 0, - "time_on_page" => 30, - "scroll_depth" => 10, - "percentage" => 80.0 - } + assert response["results"] == [ + %{"dimensions" => ["/native-and-imported"], "metrics" => [5, 5, 0, 48, 50, 20.0]}, + %{"dimensions" => ["/native-only"], "metrics" => [1, 1, 0, 60, 40, 4.0]}, + %{"dimensions" => ["/imported-only"], "metrics" => [20, 30, 0, 30, 10, 80.0]} ] end @@ -1037,22 +961,16 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do ) ]) - conn = - get( - conn, - "/api/stats/#{site.domain}/pages?period=7d&date=2020-01-02&detailed=true&with_imported=true" + response = + query_pages(conn, site, + date_range: "7d", + relative_date: "2020-01-02", + metrics: @detailed_metrics, + include: %{"imports" => true} ) - assert json_response(conn, 200)["results"] == [ - %{ - "name" => "/blog", - "visitors" => 110, - "pageviews" => 160, - "bounce_rate" => 0, - "time_on_page" => 60, - "scroll_depth" => 10, - "percentage" => nil - } + assert response["results"] == [ + %{"dimensions" => ["/blog"], "metrics" => [110, 160, 0, 60, 10, nil]} ] end @@ -1116,33 +1034,16 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do ) ]) - filters = Jason.encode!([[:is, "event:page", ["/", "/about"]]]) - - conn = - get( - conn, - "/api/stats/#{site.domain}/pages?period=day&date=2021-01-01&filters=#{filters}&detailed=true" + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + filters: [["is", "event:page", ["/", "/about"]]], + metrics: @detailed_metrics ) - assert json_response(conn, 200)["results"] == [ - %{ - "name" => "/", - "visitors" => 2, - "pageviews" => 3, - "bounce_rate" => 50, - "time_on_page" => 75, - "scroll_depth" => 0, - "percentage" => 66.67 - }, - %{ - "name" => "/about", - "visitors" => 1, - "pageviews" => 1, - "bounce_rate" => 100, - "time_on_page" => 30, - "scroll_depth" => 0, - "percentage" => 33.33 - } + assert response["results"] == [ + %{"dimensions" => ["/"], "metrics" => [2, 3, 50, 75, 0, 66.67]}, + %{"dimensions" => ["/about"], "metrics" => [1, 1, 100, 30, 0, 33.33]} ] end @@ -1206,24 +1107,15 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do ) ]) - filters = Jason.encode!([[:is_not, "event:page", ["/irrelevant", "/about"]]]) - - conn = - get( - conn, - "/api/stats/#{site.domain}/pages?period=day&date=2021-01-01&filters=#{filters}&detailed=true" + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + filters: [["is_not", "event:page", ["/irrelevant", "/about"]]], + metrics: @detailed_metrics ) - assert json_response(conn, 200)["results"] == [ - %{ - "name" => "/", - "visitors" => 2, - "pageviews" => 3, - "bounce_rate" => 50, - "time_on_page" => 75, - "scroll_depth" => 0, - "percentage" => 100.0 - } + assert response["results"] == [ + %{"dimensions" => ["/"], "metrics" => [2, 3, 50, 75, 0, 100.0]} ] end @@ -1287,42 +1179,18 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do ) ]) - filters = Jason.encode!([[:contains, "event:page", ["/blog/", "/articles/"]]]) - - conn = - get( - conn, - "/api/stats/#{site.domain}/pages?period=day&date=2021-01-01&filters=#{filters}&detailed=true" + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + filters: [["contains", "event:page", ["/blog/", "/articles/"]]], + metrics: @detailed_metrics, + order_by: [["visitors", "desc"], ["event:page", "asc"]] ) - assert json_response(conn, 200)["results"] == [ - %{ - "name" => "/articles/post-1", - "visitors" => 2, - "pageviews" => 2, - "bounce_rate" => 100, - "time_on_page" => 30, - "scroll_depth" => 0, - "percentage" => 66.67 - }, - %{ - "name" => "/blog/post-1", - "visitors" => 1, - "pageviews" => 1, - "bounce_rate" => 0, - "time_on_page" => 60, - "scroll_depth" => 0, - "percentage" => 33.33 - }, - %{ - "name" => "/blog/post-2", - "visitors" => 1, - "pageviews" => 1, - "bounce_rate" => 0, - "time_on_page" => 30, - "scroll_depth" => 0, - "percentage" => 33.33 - } + assert response["results"] == [ + %{"dimensions" => ["/articles/post-1"], "metrics" => [2, 2, 100, 30, 0, 66.67]}, + %{"dimensions" => ["/blog/post-1"], "metrics" => [1, 1, 0, 60, 0, 33.33]}, + %{"dimensions" => ["/blog/post-2"], "metrics" => [1, 1, 0, 30, 0, 33.33]} ] end @@ -1364,33 +1232,16 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do ) ]) - filters = Jason.encode!([[:contains, "event:page", ["/blog/(/", "/blog/)/"]]]) - - conn = - get( - conn, - "/api/stats/#{site.domain}/pages?period=day&date=2021-01-01&filters=#{filters}&detailed=true" + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + filters: [["contains", "event:page", ["/blog/(/", "/blog/)/"]]], + metrics: @detailed_metrics ) - assert json_response(conn, 200)["results"] == [ - %{ - "name" => "/blog/(/post-1", - "visitors" => 1, - "pageviews" => 1, - "bounce_rate" => 0, - "time_on_page" => 60, - "scroll_depth" => 0, - "percentage" => 100.0 - }, - %{ - "name" => "/blog/(/post-2", - "visitors" => 1, - "pageviews" => 1, - "bounce_rate" => 0, - "time_on_page" => 30, - "scroll_depth" => 0, - "percentage" => 100.0 - } + assert response["results"] == [ + %{"dimensions" => ["/blog/(/post-1"], "metrics" => [1, 1, 0, 60, 0, 100.0]}, + %{"dimensions" => ["/blog/(/post-2"], "metrics" => [1, 1, 0, 30, 0, 100.0]} ] end @@ -1454,33 +1305,16 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do ) ]) - filters = Jason.encode!([[:contains_not, "event:page", ["/blog/", "/articles/"]]]) - - conn = - get( - conn, - "/api/stats/#{site.domain}/pages?period=day&date=2021-01-01&filters=#{filters}&detailed=true" + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + filters: [["contains_not", "event:page", ["/blog/", "/articles/"]]], + metrics: @detailed_metrics ) - assert json_response(conn, 200)["results"] == [ - %{ - "name" => "/", - "visitors" => 2, - "pageviews" => 2, - "bounce_rate" => 50, - "time_on_page" => 600, - "scroll_depth" => 0, - "percentage" => 100.0 - }, - %{ - "name" => "/about", - "visitors" => 1, - "pageviews" => 1, - "bounce_rate" => 0, - "time_on_page" => 30, - "scroll_depth" => 0, - "percentage" => 50.0 - } + assert response["results"] == [ + %{"dimensions" => ["/"], "metrics" => [2, 2, 50, 600, 0, 100.0]}, + %{"dimensions" => ["/about"], "metrics" => [1, 1, 0, 30, 0, 50.0]} ] end @@ -1496,28 +1330,30 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do build(:pageview, pathname: "/contact") ]) - conn1 = get(conn, "/api/stats/#{site.domain}/pages?period=day") + response = query_pages(conn, site, date_range: "day") - assert json_response(conn1, 200)["results"] == [ - %{"visitors" => 3, "name" => "/", "percentage" => 50.0}, - %{"visitors" => 2, "name" => "/register", "percentage" => 33.33}, - %{"visitors" => 1, "name" => "/contact", "percentage" => 16.67} + assert response["results"] == [ + %{"dimensions" => ["/"], "metrics" => [3, 50.0]}, + %{"dimensions" => ["/register"], "metrics" => [2, 33.33]}, + %{"dimensions" => ["/contact"], "metrics" => [1, 16.67]} ] - conn2 = get(conn, "/api/stats/#{site.domain}/pages?period=day&with_imported=true") + response = query_pages(conn, site, date_range: "day", include: %{"imports" => true}) - assert json_response(conn2, 200)["results"] == [ - %{"visitors" => 4, "name" => "/", "percentage" => 66.67}, - %{"visitors" => 3, "name" => "/register", "percentage" => 50.0}, - %{"visitors" => 1, "name" => "/contact", "percentage" => 16.67} + assert response["results"] == [ + %{"dimensions" => ["/"], "metrics" => [4, 66.67]}, + %{"dimensions" => ["/register"], "metrics" => [3, 50.0]}, + %{"dimensions" => ["/contact"], "metrics" => [1, 16.67]} ] end test "returns scroll depth warning code", %{conn: conn, site: site} do - conn = - get(conn, "/api/stats/#{site.domain}/pages?period=day&detailed=true&with_imported=true") - - response = json_response(conn, 200) + response = + query_pages(conn, site, + date_range: "day", + metrics: @detailed_metrics, + include: %{"imports" => true} + ) assert response["meta"]["metric_warnings"]["scroll_depth"]["code"] == "no_imported_scroll_depth" @@ -1533,23 +1369,17 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do build(:imported_visitors, visitors: 4) ]) - filters = Jason.encode!([[:is, "event:goal", ["Visit /blog**"]]]) - q = "?period=day&filters=#{filters}&with_imported=true" - conn = get(conn, "/api/stats/#{site.domain}/pages#{q}") + response = + query_pages(conn, site, + date_range: "day", + filters: [["is", "event:goal", ["Visit /blog**"]]], + metrics: @goal_filter_metrics, + include: %{"imports" => true} + ) - assert json_response(conn, 200)["results"] == [ - %{ - "visitors" => 2, - "name" => "/blog/post-1", - "conversion_rate" => 100.0, - "total_visitors" => 2 - }, - %{ - "visitors" => 1, - "name" => "/blog", - "conversion_rate" => 100.0, - "total_visitors" => 1 - } + assert response["results"] == [ + %{"dimensions" => ["/blog/post-1"], "metrics" => [2, 100.0, 2]}, + %{"dimensions" => ["/blog"], "metrics" => [1, 100.0, 1]} ] end @@ -1590,31 +1420,15 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do ) ]) - conn = - get( - conn, - "/api/stats/#{site.domain}/pages?period=day&date=2021-01-01&detailed=true" + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + metrics: @detailed_metrics ) - assert json_response(conn, 200)["results"] == [ - %{ - "bounce_rate" => 50.0, - "time_on_page" => 465.0, - "visitors" => 2, - "pageviews" => 2, - "name" => "/", - "scroll_depth" => 0, - "percentage" => 100.0 - }, - %{ - "bounce_rate" => 0, - "time_on_page" => 30, - "visitors" => 1, - "pageviews" => 1, - "name" => "/some-other-page", - "scroll_depth" => 0, - "percentage" => 50.0 - } + assert response["results"] == [ + %{"dimensions" => ["/"], "metrics" => [2, 2, 50.0, 465.0, 0, 100.0]}, + %{"dimensions" => ["/some-other-page"], "metrics" => [1, 1, 0, 30, 0, 50.0]} ] end @@ -1639,24 +1453,15 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do ) ]) - filters = Jason.encode!([[:is, "event:hostname", ["blog.example.com"]]]) - - conn = - get( - conn, - "/api/stats/#{site.domain}/pages?period=day&date=2021-01-01&detailed=true&filters=#{filters}" + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + filters: [["is", "event:hostname", ["blog.example.com"]]], + metrics: @detailed_metrics ) - assert json_response(conn, 200)["results"] == [ - %{ - "bounce_rate" => 50, - "name" => "/about", - "pageviews" => 2, - "time_on_page" => nil, - "visitors" => 2, - "scroll_depth" => nil, - "percentage" => 100.0 - } + assert response["results"] == [ + %{"dimensions" => ["/about"], "metrics" => [2, 2, 50, nil, nil, 100.0]} ] end @@ -1776,33 +1581,16 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do ) ]) - filters = Jason.encode!([[:is, "event:hostname", ["blog.example.com"]]]) - - conn = - get( - conn, - "/api/stats/#{site.domain}/pages?period=day&date=2021-01-01&detailed=true&filters=#{filters}" + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + filters: [["is", "event:hostname", ["blog.example.com"]]], + metrics: @detailed_metrics ) - assert json_response(conn, 200)["results"] == [ - %{ - "bounce_rate" => 50, - "name" => "/about-blog", - "pageviews" => 3, - "time_on_page" => 435, - "visitors" => 2, - "scroll_depth" => 0, - "percentage" => 100.0 - }, - %{ - "bounce_rate" => 0, - "name" => "/exit-blog", - "pageviews" => 1, - "time_on_page" => 120, - "visitors" => 1, - "scroll_depth" => 0, - "percentage" => 50.0 - } + assert response["results"] == [ + %{"dimensions" => ["/about-blog"], "metrics" => [2, 3, 50, 435, 0, 100.0]}, + %{"dimensions" => ["/exit-blog"], "metrics" => [1, 1, 0, 120, 0, 50.0]} ] end @@ -1871,31 +1659,16 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do build(:imported_visitors, date: ~D[2021-01-01]) ]) - conn = - get( - conn, - "/api/stats/#{site.domain}/pages?period=day&date=2021-01-01&detailed=true&with_imported=true" + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + metrics: @detailed_metrics, + include: %{"imports" => true} ) - assert json_response(conn, 200)["results"] == [ - %{ - "bounce_rate" => 40.0, - "time_on_page" => 500, - "visitors" => 3, - "pageviews" => 3, - "scroll_depth" => 0, - "name" => "/", - "percentage" => 60.0 - }, - %{ - "bounce_rate" => 0, - "time_on_page" => 45, - "visitors" => 2, - "pageviews" => 2, - "scroll_depth" => 0, - "name" => "/some-other-page", - "percentage" => 40.0 - } + assert response["results"] == [ + %{"dimensions" => ["/"], "metrics" => [3, 3, 40.0, 500, 0, 60.0]}, + %{"dimensions" => ["/some-other-page"], "metrics" => [2, 2, 0, 45, 0, 40.0]} ] end @@ -1906,11 +1679,11 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do build(:pageview, pathname: "/page1") ]) - conn = get(conn, "/api/stats/#{site.domain}/pages?period=realtime") + response = query_pages(conn, site, date_range: "realtime") - assert json_response(conn, 200)["results"] == [ - %{"visitors" => 2, "name" => "/page1", "percentage" => 66.67}, - %{"visitors" => 1, "name" => "/page2", "percentage" => 33.33} + assert response["results"] == [ + %{"dimensions" => ["/page1"], "metrics" => [2, 66.67]}, + %{"dimensions" => ["/page2"], "metrics" => [1, 33.33]} ] end @@ -1923,17 +1696,16 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do ]) insert(:goal, site: site, event_name: "Signup") - filters = Jason.encode!([[:is, "event:goal", ["Signup"]]]) - conn = get(conn, "/api/stats/#{site.domain}/pages?period=day&filters=#{filters}") + response = + query_pages(conn, site, + date_range: "day", + filters: [["is", "event:goal", ["Signup"]]], + metrics: @goal_filter_metrics + ) - assert json_response(conn, 200)["results"] == [ - %{ - "total_visitors" => 3, - "visitors" => 1, - "name" => "/", - "conversion_rate" => 33.33 - } + assert response["results"] == [ + %{"dimensions" => ["/"], "metrics" => [1, 33.33, 3]} ] end @@ -1972,21 +1744,16 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do build(:imported_pages, page: "/ignored", visitors: 10, date: ~D[2021-01-01]) ]) - filters = Jason.encode!([[:is, "event:page", ["/"]]]) - q = "?period=day&date=2021-01-01&filters=#{filters}&detailed=true&with_imported=true" - - conn = get(conn, "/api/stats/#{site.domain}/pages#{q}") + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + filters: [["is", "event:page", ["/"]]], + metrics: @detailed_metrics, + include: %{"imports" => true} + ) - assert json_response(conn, 200)["results"] == [ - %{ - "bounce_rate" => 50, - "name" => "/", - "pageviews" => 4, - "time_on_page" => 90.0, - "visitors" => 4, - "scroll_depth" => 0, - "percentage" => 100.0 - } + assert response["results"] == [ + %{"dimensions" => ["/"], "metrics" => [4, 4, 50, 90.0, 0, 100.0]} ] end @@ -2036,30 +1803,17 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do build(:imported_pages, page: "/ignored", visitors: 10, date: ~D[2021-01-01]) ]) - filters = Jason.encode!([[:is, "event:page", ["/", "/a"]]]) - q = "?period=day&date=2021-01-01&filters=#{filters}&detailed=true&with_imported=true" - - conn = get(conn, "/api/stats/#{site.domain}/pages#{q}") + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + filters: [["is", "event:page", ["/", "/a"]]], + metrics: @detailed_metrics, + include: %{"imports" => true} + ) - assert json_response(conn, 200)["results"] == [ - %{ - "bounce_rate" => 50, - "name" => "/", - "pageviews" => 4, - "time_on_page" => 90.0, - "visitors" => 4, - "scroll_depth" => 0, - "percentage" => 80.0 - }, - %{ - "bounce_rate" => 100, - "name" => "/a", - "pageviews" => 1, - "time_on_page" => 10.0, - "visitors" => 1, - "scroll_depth" => nil, - "percentage" => 20.0 - } + assert response["results"] == [ + %{"dimensions" => ["/"], "metrics" => [4, 4, 50, 90.0, 0, 80.0]}, + %{"dimensions" => ["/a"], "metrics" => [1, 1, 100, 10.0, nil, 20.0]} ] end @@ -2109,30 +1863,17 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do build(:imported_pages, page: "/ignored", visitors: 10, date: ~D[2021-01-01]) ]) - filters = Jason.encode!([[:contains, "event:page", ["/a"]]]) - q = "?period=day&date=2021-01-01&filters=#{filters}&detailed=true&with_imported=true" - - conn = get(conn, "/api/stats/#{site.domain}/pages#{q}") + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + filters: [["contains", "event:page", ["/a"]]], + metrics: @detailed_metrics, + include: %{"imports" => true} + ) - assert json_response(conn, 200)["results"] == [ - %{ - "bounce_rate" => 50.0, - "name" => "/aaa", - "pageviews" => 4, - "time_on_page" => 90, - "visitors" => 4, - "scroll_depth" => 0, - "percentage" => 80.0 - }, - %{ - "bounce_rate" => 100.0, - "name" => "/a", - "pageviews" => 1, - "time_on_page" => 10, - "visitors" => 1, - "scroll_depth" => nil, - "percentage" => 20.0 - } + assert response["results"] == [ + %{"dimensions" => ["/aaa"], "metrics" => [4, 4, 50.0, 90, 0, 80.0]}, + %{"dimensions" => ["/a"], "metrics" => [1, 1, 100.0, 10, nil, 20.0]} ] end @@ -2156,61 +1897,30 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do ) ]) - conn = - get( - conn, - "/api/stats/#{site.domain}/pages?period=day&date=2021-01-02&comparison=previous_period&detailed=true" + response = + query_pages(conn, site, + date_range: ["2021-01-02", "2021-01-02"], + metrics: @detailed_metrics, + include: %{"compare" => "previous_period"} ) - assert json_response(conn, 200)["results"] == [ + assert response["results"] == [ %{ - "bounce_rate" => 100, + "dimensions" => ["/page2"], + "metrics" => [2, 2, 100, nil, nil, 66.67], "comparison" => %{ - "bounce_rate" => 0.0, - "pageviews" => 0, - "time_on_page" => nil, - "visitors" => 0, - "scroll_depth" => nil, - "percentage" => 0.0, - "change" => %{ - "bounce_rate" => nil, - "pageviews" => 100, - "time_on_page" => nil, - "visitors" => 100, - "scroll_depth" => nil, - "percentage" => 100 - } - }, - "name" => "/page2", - "pageviews" => 2, - "time_on_page" => nil, - "visitors" => 2, - "scroll_depth" => nil, - "percentage" => 66.67 + "dimensions" => ["/page2"], + "metrics" => [0, 0, 0.0, nil, nil, 0.0], + "change" => [100, 100, nil, nil, nil, 100] + } }, %{ - "bounce_rate" => 100, - "name" => "/page1", - "pageviews" => 1, - "time_on_page" => nil, - "visitors" => 1, - "scroll_depth" => nil, - "percentage" => 33.33, + "dimensions" => ["/page1"], + "metrics" => [1, 1, 100, nil, nil, 33.33], "comparison" => %{ - "bounce_rate" => 100, - "pageviews" => 1, - "time_on_page" => nil, - "visitors" => 1, - "scroll_depth" => nil, - "percentage" => 100.0, - "change" => %{ - "bounce_rate" => 0, - "pageviews" => 0, - "time_on_page" => nil, - "visitors" => 0, - "scroll_depth" => nil, - "percentage" => -67 - } + "dimensions" => ["/page1"], + "metrics" => [1, 1, 100, nil, nil, 100.0], + "change" => [0, 0, 0, nil, nil, -67] } } ] @@ -2238,40 +1948,16 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do build(:pageview, pathname: "/b1", timestamp: ~N[2021-01-01 00:00:00]) ]) - conn = - get( - conn, - "/api/stats/#{cv.domain}/pages?period=day&date=2021-01-01&detailed=true" + response = + query_pages(conn, cv, + date_range: ["2021-01-01", "2021-01-01"], + metrics: @detailed_metrics ) - assert json_response(conn, 200)["results"] == [ - %{ - "bounce_rate" => 100, - "name" => "/b1", - "pageviews" => 3, - "time_on_page" => nil, - "visitors" => 3, - "scroll_depth" => nil, - "percentage" => 50.0 - }, - %{ - "bounce_rate" => 100, - "name" => "/a2", - "pageviews" => 2, - "time_on_page" => nil, - "visitors" => 2, - "scroll_depth" => nil, - "percentage" => 33.33 - }, - %{ - "bounce_rate" => 100, - "name" => "/a1", - "pageviews" => 1, - "time_on_page" => nil, - "visitors" => 1, - "scroll_depth" => nil, - "percentage" => 16.67 - } + assert response["results"] == [ + %{"dimensions" => ["/b1"], "metrics" => [3, 3, 100, nil, nil, 50.0]}, + %{"dimensions" => ["/a2"], "metrics" => [2, 2, 100, nil, nil, 33.33]}, + %{"dimensions" => ["/a1"], "metrics" => [1, 1, 100, nil, nil, 16.67]} ] end end @@ -2310,25 +1996,16 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do ) ]) - conn = get(conn, "/api/stats/#{site.domain}/entry-pages?period=day&date=2021-01-01") + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + dimensions: ["visit:entry_page"], + metrics: ["visitors", "visits", "bounce_rate", "visit_duration", "percentage"] + ) - assert json_response(conn, 200)["results"] == [ - %{ - "visitors" => 2, - "visits" => 2, - "name" => "/page1", - "visit_duration" => 0, - "bounce_rate" => 100, - "percentage" => 66.67 - }, - %{ - "visitors" => 1, - "visits" => 2, - "name" => "/page2", - "visit_duration" => 450, - "bounce_rate" => 50, - "percentage" => 33.33 - } + assert response["results"] == [ + %{"dimensions" => ["/page1"], "metrics" => [2, 2, 100, 0, 66.67]}, + %{"dimensions" => ["/page2"], "metrics" => [1, 2, 50, 450, 33.33]} ] end @@ -2364,31 +2041,17 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do ) ]) - filters = Jason.encode!([[:is, "event:props:author", ["John Doe"]]]) - - conn = - get( - conn, - "/api/stats/#{site.domain}/entry-pages?period=day&date=2021-01-01&filters=#{filters}" + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + dimensions: ["visit:entry_page"], + filters: [["is", "event:props:author", ["John Doe"]]], + metrics: ["visitors", "visits", "bounce_rate", "visit_duration", "percentage"] ) - assert json_response(conn, 200)["results"] == [ - %{ - "visitors" => 1, - "visits" => 1, - "name" => "/blog", - "visit_duration" => 60, - "bounce_rate" => 0, - "percentage" => 50.0 - }, - %{ - "visitors" => 1, - "visits" => 1, - "name" => "/blog/john-2", - "visit_duration" => 0, - "bounce_rate" => 100, - "percentage" => 50.0 - } + assert response["results"] == [ + %{"dimensions" => ["/blog"], "metrics" => [1, 1, 0, 60, 50.0]}, + %{"dimensions" => ["/blog/john-2"], "metrics" => [1, 1, 100, 0, 50.0]} ] end @@ -2420,6 +2083,10 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do ]) populate_stats(site, [ + build(:imported_visitors, + date: ~D[2021-01-01], + visitors: 2 + ), build(:imported_entry_pages, entry_page: "/page2", date: ~D[2021-01-01], @@ -2429,50 +2096,29 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do ) ]) - conn1 = get(conn, "/api/stats/#{site.domain}/entry-pages?period=day&date=2021-01-01") + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + dimensions: ["visit:entry_page"], + metrics: ["visitors", "visits", "bounce_rate", "visit_duration", "percentage"] + ) - assert json_response(conn1, 200)["results"] == [ - %{ - "visitors" => 2, - "visits" => 2, - "name" => "/page1", - "visit_duration" => 0, - "bounce_rate" => 100, - "percentage" => 66.67 - }, - %{ - "visitors" => 1, - "visits" => 2, - "name" => "/page2", - "visit_duration" => 450, - "bounce_rate" => 50, - "percentage" => 33.33 - } + assert response["results"] == [ + %{"dimensions" => ["/page1"], "metrics" => [2, 2, 100, 0, 66.67]}, + %{"dimensions" => ["/page2"], "metrics" => [1, 2, 50, 450, 33.33]} ] - conn2 = - get( - conn, - "/api/stats/#{site.domain}/entry-pages?period=day&date=2021-01-01&with_imported=true" + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + dimensions: ["visit:entry_page"], + metrics: ["visitors", "visits", "bounce_rate", "visit_duration", "percentage"], + include: %{"imports" => true} ) - assert json_response(conn2, 200)["results"] == [ - %{ - "visitors" => 3, - "visits" => 5, - "name" => "/page2", - "visit_duration" => 240.0, - "bounce_rate" => 20.0, - "percentage" => 60.0 - }, - %{ - "visitors" => 2, - "visits" => 2, - "name" => "/page1", - "visit_duration" => 0.0, - "bounce_rate" => 100.0, - "percentage" => 40.0 - } + assert response["results"] == [ + %{"dimensions" => ["/page2"], "metrics" => [3, 5, 20.0, 240.0, 60.0]}, + %{"dimensions" => ["/page1"], "metrics" => [2, 2, 100.0, 0.0, 40.0]} ] end @@ -2514,32 +2160,19 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do ) ]) - filters = Jason.encode!([[:is, "event:hostname", ["es.example.com"]]]) - - conn = - get( - conn, - "/api/stats/#{site.domain}/entry-pages?period=day&date=2021-01-01&filters=#{filters}" - ) - # We're going to only join sessions where the exit hostname matches the filter - assert json_response(conn, 200)["results"] == [ - %{ - "name" => "/page1", - "visit_duration" => 0, - "visitors" => 1, - "visits" => 1, - "bounce_rate" => 100, - "percentage" => 50.0 - }, - %{ - "name" => "/page2", - "visit_duration" => 0, - "visitors" => 1, - "visits" => 1, - "bounce_rate" => 100, - "percentage" => 50.0 - } + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + dimensions: ["visit:entry_page"], + filters: [["is", "event:hostname", ["es.example.com"]]], + metrics: ["visitors", "visits", "bounce_rate", "visit_duration", "percentage"], + order_by: [["visitors", "desc"], ["visit:entry_page", "asc"]] + ) + + assert response["results"] == [ + %{"dimensions" => ["/page1"], "metrics" => [1, 1, 100, 0, 50.0]}, + %{"dimensions" => ["/page2"], "metrics" => [1, 1, 100, 0, 50.0]} ] end @@ -2559,31 +2192,32 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do insert(:goal, site: site, event_name: "Signup") request = fn conn, opts -> - page = Keyword.fetch!(opts, :page) - limit = Keyword.fetch!(opts, :limit) - filters = Jason.encode!([[:is, "event:goal", ["Signup"]]]) - conn - |> get( - "/api/stats/#{site.domain}/pages?date=2021-01-01&period=day&filters=#{filters}&limit=#{limit}&page=#{page}" + |> query_pages(site, + metrics: ["visitors"], + pagination: %{ + "offset" => Keyword.fetch!(opts, :offset), + "limit" => Keyword.fetch!(opts, :limit) + }, + filters: [["is", "event:goal", ["Signup"]]], + order_by: [["event:page", "asc"]] ) - |> json_response(200) |> Map.get("results") - |> Enum.map(fn %{"name" => "/signup/" <> seq} -> + |> Enum.map(fn %{"dimensions" => ["/signup/" <> seq]} -> seq end) end - assert List.first(request.(conn, page: 1, limit: 100)) == "01" - assert List.last(request.(conn, page: 1, limit: 100)) == "30" - assert List.last(request.(conn, page: 1, limit: 29)) == "29" - assert ["01", "02"] = request.(conn, page: 1, limit: 2) - assert ["03", "04"] = request.(conn, page: 2, limit: 2) - assert ["01", "02", "03", "04", "05"] = request.(conn, page: 1, limit: 5) - assert ["06", "07", "08", "09", "10"] = request.(conn, page: 2, limit: 5) - assert ["11", "12", "13", "14", "15"] = request.(conn, page: 3, limit: 5) - assert ["20"] = request.(conn, page: 20, limit: 1) - assert [] = request.(conn, page: 31, limit: 1) + assert List.first(request.(conn, offset: 0, limit: 100)) == "01" + assert List.last(request.(conn, offset: 0, limit: 100)) == "30" + assert List.last(request.(conn, offset: 0, limit: 29)) == "29" + assert ["01", "02"] = request.(conn, offset: 0, limit: 2) + assert ["03", "04"] = request.(conn, offset: 2, limit: 2) + assert ["01", "02", "03", "04", "05"] = request.(conn, offset: 0, limit: 5) + assert ["06", "07", "08", "09", "10"] = request.(conn, offset: 5, limit: 5) + assert ["11", "12", "13", "14", "15"] = request.(conn, offset: 10, limit: 5) + assert ["20"] = request.(conn, offset: 19, limit: 1) + assert [] = request.(conn, offset: 30, limit: 1) end test "calculates conversion_rate when filtering for goal", %{conn: conn, site: site} do @@ -2621,31 +2255,23 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do ]) insert(:goal, site: site, event_name: "Signup") - filters = Jason.encode!([[:is, "event:goal", ["Signup"]]]) - conn = - get( - conn, - "/api/stats/#{site.domain}/entry-pages?period=day&date=2021-01-01&filters=#{filters}" + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + dimensions: ["visit:entry_page"], + filters: [["is", "event:goal", ["Signup"]]], + metrics: ["visitors", "group_conversion_rate", "total_visitors"], + order_by: [["visitors", "desc"], ["visit:entry_page", "asc"]] ) - assert json_response(conn, 200)["results"] == [ - %{ - "total_visitors" => 2, - "visitors" => 1, - "name" => "/page1", - "conversion_rate" => 50.0 - }, - %{ - "total_visitors" => 1, - "visitors" => 1, - "name" => "/page2", - "conversion_rate" => 100.0 - } + assert response["results"] == [ + %{"dimensions" => ["/page1"], "metrics" => [1, 50.0, 2]}, + %{"dimensions" => ["/page2"], "metrics" => [1, 100.0, 1]} ] end - test "ignores entry pages from sessions with only custom events", %{conn: conn, site: site} do + test "can filter out empty entry pages (sessions with only custom events)", %{conn: conn, site: site} do populate_stats(site, [ build(:event, name: "Signup", @@ -2654,13 +2280,15 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do ) ]) - conn = - get( - conn, - "/api/stats/#{site.domain}/entry-pages?period=day&date=2021-01-01" + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + dimensions: ["visit:entry_page"], + filters: [["is_not", "visit:entry_page", [""]]], + metrics: ["visitors", "visits", "bounce_rate", "visit_duration", "percentage"] ) - assert json_response(conn, 200)["results"] == [] + assert response["results"] == [] end test "filter by :matches_member entry_page with imported data", %{conn: conn, site: site} do @@ -2686,36 +2314,19 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do ) ]) - filters = Jason.encode!([[:contains, "visit:entry_page", ["/a", "/b"]]]) - q = "?period=day&date=2021-01-01&filters=#{filters}&detailed=true&with_imported=true" - - conn = get(conn, "/api/stats/#{site.domain}/entry-pages#{q}") + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + dimensions: ["visit:entry_page"], + filters: [["contains", "visit:entry_page", ["/a", "/b"]]], + metrics: ["visitors", "visits", "bounce_rate", "visit_duration", "percentage"], + include: %{"imports" => true} + ) - assert json_response(conn, 200)["results"] == [ - %{ - "visit_duration" => 100.0, - "name" => "/a", - "visits" => 10, - "visitors" => 6, - "bounce_rate" => 10.0, - "percentage" => 66.67 - }, - %{ - "visit_duration" => 50.0, - "name" => "/bbb", - "visits" => 2, - "visitors" => 2, - "bounce_rate" => 0.0, - "percentage" => 22.22 - }, - %{ - "visit_duration" => 0, - "name" => "/aaa", - "visits" => 1, - "visitors" => 1, - "bounce_rate" => 100.0, - "percentage" => 11.11 - } + assert response["results"] == [ + %{"dimensions" => ["/a"], "metrics" => [6, 10, 10.0, 100.0, 66.67]}, + %{"dimensions" => ["/bbb"], "metrics" => [2, 2, 0.0, 50.0, 22.22]}, + %{"dimensions" => ["/aaa"], "metrics" => [1, 1, 100.0, 0, 11.11]} ] end end @@ -2745,23 +2356,16 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do ) ]) - conn = get(conn, "/api/stats/#{site.domain}/exit-pages?period=day&date=2021-01-01") + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + dimensions: ["visit:exit_page"], + metrics: ["visitors", "visits", "exit_rate", "percentage"] + ) - assert json_response(conn, 200)["results"] == [ - %{ - "name" => "/page1", - "visitors" => 2, - "visits" => 2, - "exit_rate" => 66.7, - "percentage" => 66.67 - }, - %{ - "name" => "/page2", - "visitors" => 1, - "visits" => 1, - "exit_rate" => 100, - "percentage" => 33.33 - } + assert response["results"] == [ + %{"dimensions" => ["/page1"], "metrics" => [2, 2, 66.7, 66.67]}, + %{"dimensions" => ["/page2"], "metrics" => [1, 1, 100, 33.33]} ] end @@ -2787,27 +2391,17 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do ) ]) - conn = - get( - conn, - "/api/stats/#{site.domain}/exit-pages?period=day&date=2021-01-01&order_by=#{Jason.encode!([["visits", "asc"]])}" + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + dimensions: ["visit:exit_page"], + metrics: ["visitors", "visits", "exit_rate", "percentage"], + order_by: [["visits", "asc"]] ) - assert json_response(conn, 200)["results"] == [ - %{ - "name" => "/page2", - "visitors" => 1, - "visits" => 1, - "exit_rate" => 100.0, - "percentage" => 33.33 - }, - %{ - "name" => "/page1", - "visitors" => 2, - "visits" => 2, - "exit_rate" => 66.7, - "percentage" => 66.67 - } + assert response["results"] == [ + %{"dimensions" => ["/page2"], "metrics" => [1, 1, 100.0, 33.33]}, + %{"dimensions" => ["/page1"], "metrics" => [2, 2, 66.7, 66.67]} ] end @@ -2844,17 +2438,18 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do ) ]) - filters = Jason.encode!([[:is, "event:hostname", ["es.example.com"]]]) - - conn = - get( - conn, - "/api/stats/#{site.domain}/exit-pages?period=day&date=2021-01-01&filters=#{filters}" + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + dimensions: ["visit:exit_page"], + filters: [["is", "event:hostname", ["es.example.com"]]], + metrics: ["visitors", "visits", "percentage"] ) # We're going to only join sessions where the entry hostname matches the filter - assert json_response(conn, 200)["results"] == - [%{"name" => "/page1", "visitors" => 1, "visits" => 1, "percentage" => 100.0}] + assert response["results"] == [ + %{"dimensions" => ["/page1"], "metrics" => [1, 1, 100.0]} + ] end test "returns top exit pages filtered by custom pageview props", %{conn: conn, site: site} do @@ -2883,16 +2478,16 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do ) ]) - filters = Jason.encode!([[:is, "event:props:author", ["John Doe"]]]) - - conn = - get( - conn, - "/api/stats/#{site.domain}/exit-pages?period=day&date=2021-01-01&filters=#{filters}" + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + dimensions: ["visit:exit_page"], + filters: [["is", "event:props:author", ["John Doe"]]], + metrics: ["visitors", "visits", "percentage"] ) - assert json_response(conn, 200)["results"] == [ - %{"name" => "/", "visitors" => 1, "visits" => 1, "percentage" => 100.0} + assert response["results"] == [ + %{"dimensions" => ["/"], "metrics" => [1, 1, 100.0]} ] end @@ -2933,50 +2528,35 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do ) ]) - conn1 = get(conn, "/api/stats/#{site.domain}/exit-pages?period=day&date=2021-01-01") + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + dimensions: ["visit:exit_page"], + filters: [["is_not", "visit:exit_page", [""]]], + metrics: ["visitors", "visits", "exit_rate", "percentage"] + ) - assert json_response(conn1, 200)["results"] == [ - %{ - "name" => "/page1", - "visitors" => 2, - "visits" => 2, - "exit_rate" => 66.7, - "percentage" => 66.67 - }, - %{ - "name" => "/page2", - "visitors" => 1, - "visits" => 1, - "exit_rate" => 100.0, - "percentage" => 33.33 - } + assert response["results"] == [ + %{"dimensions" => ["/page1"], "metrics" => [2, 2, 66.7, 66.67]}, + %{"dimensions" => ["/page2"], "metrics" => [1, 1, 100.0, 33.33]} ] - conn2 = - get( - conn, - "/api/stats/#{site.domain}/exit-pages?period=day&date=2021-01-01&with_imported=true" + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + dimensions: ["visit:exit_page"], + filters: [["is_not", "visit:exit_page", [""]]], + metrics: ["visitors", "visits", "exit_rate", "percentage"], + include: %{"imports" => true} ) - assert json_response(conn2, 200)["results"] == [ - %{ - "name" => "/page2", - "visitors" => 3, - "visits" => 4, - "exit_rate" => 80.0, - "percentage" => 60.0 - }, - %{ - "name" => "/page1", - "visitors" => 2, - "visits" => 2, - "exit_rate" => 66.7, - "percentage" => 40.0 - } + assert response["results"] == [ + %{"dimensions" => ["/page2"], "metrics" => [3, 4, 80.0, 60.0]}, + %{"dimensions" => ["/page1"], "metrics" => [2, 2, 66.7, 40.0]} ] end - test "calculates correct exit rate and conversion_rate when filtering for goal", %{ + test "returns top exit pages when filtering for goal", %{ conn: conn, site: site } do @@ -3009,31 +2589,23 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do ]) insert(:goal, site: site, event_name: "Signup") - filters = Jason.encode!([[:is, "event:goal", ["Signup"]]]) - conn = - get( - conn, - "/api/stats/#{site.domain}/exit-pages?period=day&date=2021-01-01&filters=#{filters}" + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + dimensions: ["visit:exit_page"], + filters: [["is", "event:goal", ["Signup"]]], + metrics: ["visitors", "group_conversion_rate", "total_visitors"], + order_by: [["visitors", "desc"], ["visit:exit_page", "asc"]] ) - assert json_response(conn, 200)["results"] == [ - %{ - "name" => "/exit1", - "visitors" => 1, - "total_visitors" => 1, - "conversion_rate" => 100.0 - }, - %{ - "name" => "/exit2", - "visitors" => 1, - "total_visitors" => 1, - "conversion_rate" => 100.0 - } + assert response["results"] == [ + %{"dimensions" => ["/exit1"], "metrics" => [1, 100.0, 1]}, + %{"dimensions" => ["/exit2"], "metrics" => [1, 100.0, 1]} ] end - test "calculates correct exit rate when filtering for page", %{conn: conn, site: site} do + test "returns top exit pages when filtering for page", %{conn: conn, site: site} do populate_stats(site, [ build(:pageview, user_id: 1, @@ -3062,21 +2634,21 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do ) ]) - filters = Jason.encode!([[:is, "event:page", ["/exit1"]]]) - - conn = - get( - conn, - "/api/stats/#{site.domain}/exit-pages?period=day&date=2021-01-01&filters=#{filters}" + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + dimensions: ["visit:exit_page"], + filters: [["is", "event:page", ["/exit1"]]], + metrics: ["visitors", "visits", "percentage"] ) - assert json_response(conn, 200)["results"] == [ - %{"name" => "/exit1", "visitors" => 1, "visits" => 1, "percentage" => 50.0}, - %{"name" => "/exit2", "visitors" => 1, "visits" => 1, "percentage" => 50.0} + assert response["results"] == [ + %{"dimensions" => ["/exit1"], "metrics" => [1, 1, 50.0]}, + %{"dimensions" => ["/exit2"], "metrics" => [1, 1, 50.0]} ] end - test "ignores exit pages from sessions with only custom events", %{conn: conn, site: site} do + test "can filter out empty exit pages (sessions with only custom events)", %{conn: conn, site: site} do populate_stats(site, [ build(:event, name: "Signup", @@ -3085,13 +2657,15 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do ) ]) - conn = - get( - conn, - "/api/stats/#{site.domain}/exit-pages?period=day&date=2021-01-01" + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + dimensions: ["visit:exit_page"], + filters: [["is_not", "visit:exit_page", [""]]], + metrics: ["visitors", "visits", "exit_rate", "percentage"] ) - assert json_response(conn, 200)["results"] == [] + assert response["results"] == [] end test "filter by :is_not exit_page with imported data", %{conn: conn, site: site} do @@ -3119,33 +2693,19 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do build(:imported_pages, page: "/bbb", pageviews: 2, date: ~D[2021-01-01]) ]) - filters = Jason.encode!([[:is_not, "visit:exit_page", ["/ignored"]]]) - q = "?period=day&date=2021-01-01&filters=#{filters}&detailed=true&with_imported=true" - - conn = get(conn, "/api/stats/#{site.domain}/exit-pages#{q}") + response = + query_pages(conn, site, + date_range: ["2021-01-01", "2021-01-01"], + dimensions: ["visit:exit_page"], + filters: [["is_not", "visit:exit_page", ["/ignored"]]], + metrics: ["visitors", "visits", "exit_rate", "percentage"], + include: %{"imports" => true} + ) - assert json_response(conn, 200)["results"] == [ - %{ - "exit_rate" => 50.0, - "name" => "/a", - "visits" => 10, - "visitors" => 6, - "percentage" => 66.67 - }, - %{ - "exit_rate" => 100.0, - "name" => "/bbb", - "visits" => 2, - "visitors" => 2, - "percentage" => 22.22 - }, - %{ - "exit_rate" => 100.0, - "name" => "/aaa", - "visits" => 1, - "visitors" => 1, - "percentage" => 11.11 - } + assert response["results"] == [ + %{"dimensions" => ["/a"], "metrics" => [6, 10, 50.0, 66.67]}, + %{"dimensions" => ["/bbb"], "metrics" => [2, 2, 100.0, 22.22]}, + %{"dimensions" => ["/aaa"], "metrics" => [1, 1, 100.0, 11.11]} ] end @@ -3192,71 +2752,81 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do insert(:goal, %{site: site, event_name: "Payment", currency: :USD}) - filters = Jason.encode!([[:is, "event:goal", ["Payment"]]]) - order_by = Jason.encode!([["visitors", "desc"]]) - - q = "?filters=#{filters}&order_by=#{order_by}&detailed=true&period=day&page=1&limit=100" - - conn = - get( - conn, - "/api/stats/#{site.domain}/entry-pages#{q}" - ) - - assert json_response(conn, 200)["results"] == [ - %{ - "average_revenue" => %{ - "currency" => "USD", - "long" => "$1,500.00", - "short" => "$1.5K", - "value" => 1500.0 - }, - "conversion_rate" => 100.0, - "name" => "/first", - "total_revenue" => %{ - "currency" => "USD", - "long" => "$3,000.00", - "short" => "$3.0K", - "value" => 3000.0 - }, - "total_visitors" => 2, - "visitors" => 2 + response = + query_pages(conn, site, + date_range: "day", + dimensions: ["visit:entry_page"], + filters: [["is", "event:goal", ["Payment"]], ["is_not", "visit:entry_page", [""]]], + metrics: [ + "visitors", + "group_conversion_rate", + "total_visitors", + "average_revenue", + "total_revenue" + ], + order_by: [["visitors", "desc"], ["visit:entry_page", "asc"]] + ) + + assert response["results"] == [ + %{ + "dimensions" => ["/first"], + "metrics" => [ + 2, + 100.0, + 2, + %{ + "currency" => "USD", + "long" => "$1,500.00", + "short" => "$1.5K", + "value" => 1500.0 + }, + %{ + "currency" => "USD", + "long" => "$3,000.00", + "short" => "$3.0K", + "value" => 3000.0 + } + ] }, %{ - "average_revenue" => %{ - "currency" => "USD", - "long" => "$3,500.00", - "short" => "$3.5K", - "value" => 3500.0 - }, - "conversion_rate" => 100.0, - "name" => "/second", - "total_revenue" => %{ - "currency" => "USD", - "long" => "$7,000.00", - "short" => "$7.0K", - "value" => 7000.0 - }, - "total_visitors" => 1, - "visitors" => 1 + "dimensions" => ["/second"], + "metrics" => [ + 1, + 100.0, + 1, + %{ + "currency" => "USD", + "long" => "$3,500.00", + "short" => "$3.5K", + "value" => 3500.0 + }, + %{ + "currency" => "USD", + "long" => "$7,000.00", + "short" => "$7.0K", + "value" => 7000.0 + } + ] }, %{ - "average_revenue" => %{ - "currency" => "USD", - "long" => "$2,500.00", - "short" => "$2.5K", - "value" => 2500.0 - }, - "conversion_rate" => 100.0, - "name" => "/third", - "total_revenue" => %{ - "currency" => "USD", - "long" => "$2,500.00", - "short" => "$2.5K", - "value" => 2500.0 - }, - "total_visitors" => 1, - "visitors" => 1 + "dimensions" => ["/third"], + "metrics" => [ + 1, + 100.0, + 1, + %{ + "currency" => "USD", + "long" => "$2,500.00", + "short" => "$2.5K", + "value" => 2500.0 + }, + %{ + "currency" => "USD", + "long" => "$2,500.00", + "short" => "$2.5K", + "value" => 2500.0 + } + ] } ] end @@ -3307,71 +2877,81 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do insert(:goal, %{site: site, event_name: "Payment", currency: :USD}) - filters = Jason.encode!([[:is, "event:goal", ["Payment"]]]) - order_by = Jason.encode!([["visitors", "desc"]]) - - q = "?filters=#{filters}&order_by=#{order_by}&detailed=true&period=day&page=1&limit=100" - - conn = - get( - conn, - "/api/stats/#{site.domain}/exit-pages#{q}" - ) - - assert json_response(conn, 200)["results"] == [ - %{ - "average_revenue" => %{ - "currency" => "USD", - "long" => "$1,500.00", - "short" => "$1.5K", - "value" => 1500.0 - }, - "conversion_rate" => 100.0, - "name" => "/exit_first", - "total_revenue" => %{ - "currency" => "USD", - "long" => "$3,000.00", - "short" => "$3.0K", - "value" => 3000.0 - }, - "total_visitors" => 2, - "visitors" => 2 + response = + query_pages(conn, site, + date_range: "day", + dimensions: ["visit:exit_page"], + filters: [["is", "event:goal", ["Payment"]], ["is_not", "visit:exit_page", [""]]], + metrics: [ + "visitors", + "group_conversion_rate", + "total_visitors", + "average_revenue", + "total_revenue" + ], + order_by: [["visitors", "desc"], ["visit:exit_page", "asc"]] + ) + + assert response["results"] == [ + %{ + "dimensions" => ["/exit_first"], + "metrics" => [ + 2, + 100.0, + 2, + %{ + "currency" => "USD", + "long" => "$1,500.00", + "short" => "$1.5K", + "value" => 1500.0 + }, + %{ + "currency" => "USD", + "long" => "$3,000.00", + "short" => "$3.0K", + "value" => 3000.0 + } + ] }, %{ - "average_revenue" => %{ - "currency" => "USD", - "long" => "$3,500.00", - "short" => "$3.5K", - "value" => 3500.0 - }, - "conversion_rate" => 100.0, - "name" => "/exit_second", - "total_revenue" => %{ - "currency" => "USD", - "long" => "$7,000.00", - "short" => "$7.0K", - "value" => 7000.0 - }, - "total_visitors" => 1, - "visitors" => 1 + "dimensions" => ["/exit_second"], + "metrics" => [ + 1, + 100.0, + 1, + %{ + "currency" => "USD", + "long" => "$3,500.00", + "short" => "$3.5K", + "value" => 3500.0 + }, + %{ + "currency" => "USD", + "long" => "$7,000.00", + "short" => "$7.0K", + "value" => 7000.0 + } + ] }, %{ - "average_revenue" => %{ - "currency" => "USD", - "long" => "$2,500.00", - "short" => "$2.5K", - "value" => 2500.0 - }, - "conversion_rate" => 100.0, - "name" => "/third", - "total_revenue" => %{ - "currency" => "USD", - "long" => "$2,500.00", - "short" => "$2.5K", - "value" => 2500.0 - }, - "total_visitors" => 1, - "visitors" => 1 + "dimensions" => ["/third"], + "metrics" => [ + 1, + 100.0, + 1, + %{ + "currency" => "USD", + "long" => "$2,500.00", + "short" => "$2.5K", + "value" => 2500.0 + }, + %{ + "currency" => "USD", + "long" => "$2,500.00", + "short" => "$2.5K", + "value" => 2500.0 + } + ] } ] end @@ -3428,89 +3008,89 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do insert(:goal, %{site: site, event_name: "Payment", currency: :USD}) - filters = Jason.encode!([[:is, "event:goal", ["Payment"]]]) - order_by = Jason.encode!([["visitors", "desc"]]) - - q = "?filters=#{filters}&order_by=#{order_by}&detailed=true&period=day&page=1&limit=100" - - conn = - get( - conn, - "/api/stats/#{site.domain}/pages#{q}" - ) - - assert json_response(conn, 200)["results"] == [ - %{ - "average_revenue" => %{ - "currency" => "USD", - "long" => "$0.00", - "short" => "$0.0", - "value" => 0.0 - }, - "conversion_rate" => 100.0, - "name" => "/nopay", - "total_revenue" => %{ - "currency" => "USD", - "long" => "$0.00", - "short" => "$0.0", - "value" => 0.0 - }, - "total_visitors" => 3, - "visitors" => 3 + response = + query_pages(conn, site, + date_range: "day", + filters: [["is", "event:goal", ["Payment"]]], + metrics: [ + "visitors", + "group_conversion_rate", + "total_visitors", + "average_revenue", + "total_revenue" + ] + ) + + assert response["results"] == [ + %{ + "dimensions" => ["/nopay"], + "metrics" => [ + 3, + 100.0, + 3, + %{"currency" => "USD", "long" => "$0.00", "short" => "$0.0", "value" => 0.0}, + %{"currency" => "USD", "long" => "$0.00", "short" => "$0.0", "value" => 0.0} + ] }, %{ - "average_revenue" => %{ - "currency" => "USD", - "long" => "$1,500.00", - "short" => "$1.5K", - "value" => 1500.0 - }, - "conversion_rate" => 100.0, - "name" => "/purchase/first", - "total_revenue" => %{ - "currency" => "USD", - "long" => "$3,000.00", - "short" => "$3.0K", - "value" => 3000.0 - }, - "total_visitors" => 2, - "visitors" => 2 + "dimensions" => ["/purchase/first"], + "metrics" => [ + 2, + 100.0, + 2, + %{ + "currency" => "USD", + "long" => "$1,500.00", + "short" => "$1.5K", + "value" => 1500.0 + }, + %{ + "currency" => "USD", + "long" => "$3,000.00", + "short" => "$3.0K", + "value" => 3000.0 + } + ] }, %{ - "average_revenue" => %{ - "currency" => "USD", - "long" => "$3,500.00", - "short" => "$3.5K", - "value" => 3500.0 - }, - "conversion_rate" => 100.0, - "name" => "/purchase/second", - "total_revenue" => %{ - "currency" => "USD", - "long" => "$7,000.00", - "short" => "$7.0K", - "value" => 7000.0 - }, - "total_visitors" => 1, - "visitors" => 1 + "dimensions" => ["/purchase/second"], + "metrics" => [ + 1, + 100.0, + 1, + %{ + "currency" => "USD", + "long" => "$3,500.00", + "short" => "$3.5K", + "value" => 3500.0 + }, + %{ + "currency" => "USD", + "long" => "$7,000.00", + "short" => "$7.0K", + "value" => 7000.0 + } + ] }, %{ - "average_revenue" => %{ - "currency" => "USD", - "long" => "$2,500.00", - "short" => "$2.5K", - "value" => 2500.0 - }, - "conversion_rate" => 100.0, - "name" => "/purchase/third", - "total_revenue" => %{ - "currency" => "USD", - "long" => "$2,500.00", - "short" => "$2.5K", - "value" => 2500.0 - }, - "total_visitors" => 1, - "visitors" => 1 + "dimensions" => ["/purchase/third"], + "metrics" => [ + 1, + 100.0, + 1, + %{ + "currency" => "USD", + "long" => "$2,500.00", + "short" => "$2.5K", + "value" => 2500.0 + }, + %{ + "currency" => "USD", + "long" => "$2,500.00", + "short" => "$2.5K", + "value" => 2500.0 + } + ] } ] end From 0c59aed5986dcb36aae44cc070ccb0b86958828e Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Thu, 19 Mar 2026 16:15:37 +0000 Subject: [PATCH 02/40] make total_visitors an official metric --- lib/plausible/stats/metrics.ex | 1 + .../stats/query/query_parse_and_build_test.exs | 13 +++++++++++++ 2 files changed, 14 insertions(+) diff --git a/lib/plausible/stats/metrics.ex b/lib/plausible/stats/metrics.ex index fc603b706e21..191b3a363421 100644 --- a/lib/plausible/stats/metrics.ex +++ b/lib/plausible/stats/metrics.ex @@ -19,6 +19,7 @@ defmodule Plausible.Stats.Metrics do :visit_duration, :events, :conversion_rate, + :total_visitors, :group_conversion_rate, :time_on_page, :percentage, diff --git a/test/plausible/stats/query/query_parse_and_build_test.exs b/test/plausible/stats/query/query_parse_and_build_test.exs index 33add7a73963..0ba5271e7c73 100644 --- a/test/plausible/stats/query/query_parse_and_build_test.exs +++ b/test/plausible/stats/query/query_parse_and_build_test.exs @@ -92,6 +92,19 @@ defmodule Plausible.Stats.Query.QueryParseAndBuildTest do assert error =~ "Invalid metric" end + test "public API does not recognize total_visitors metric", %{site: site} do + params = %{ + "site_id" => site.domain, + "metrics" => ["total_visitors"], + "date_range" => "all" + } + + assert {:error, %QueryError{message: error}} = + Query.parse_and_build(site, params, now: @now) + + assert error =~ "Invalid metric" + end + test "valid metrics passed", %{site: site} do params = %{ "site_id" => site.domain, From eaf2c59ec1c35722a64cecafe6c6d22352a956e7 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Thu, 19 Mar 2026 16:26:11 +0000 Subject: [PATCH 03/40] Dashboard.QueryParser: add pagination and order_by --- lib/plausible/stats/api_query_parser.ex | 4 ++-- lib/plausible/stats/dashboard/query_parser.ex | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/lib/plausible/stats/api_query_parser.ex b/lib/plausible/stats/api_query_parser.ex index 53af31245ce8..35837f1eba5e 100644 --- a/lib/plausible/stats/api_query_parser.ex +++ b/lib/plausible/stats/api_query_parser.ex @@ -325,11 +325,11 @@ defmodule Plausible.Stats.ApiQueryParser do defp parse_include_entry(key, _value), do: {:error, %QueryError{code: :invalid_include, message: "Invalid include key'#{i(key)}'."}} - defp parse_pagination(pagination) when is_map(pagination) do + def parse_pagination(pagination) when is_map(pagination) do {:ok, Map.merge(@default_pagination, atomize_keys(pagination))} end - defp parse_pagination(nil), do: {:ok, @default_pagination} + def parse_pagination(nil), do: {:ok, @default_pagination} defp atomize_keys(map) when is_map(map) do Map.new(map, fn {key, value} -> diff --git a/lib/plausible/stats/dashboard/query_parser.ex b/lib/plausible/stats/dashboard/query_parser.ex index d15f4f8de055..ebf4ca4591ba 100644 --- a/lib/plausible/stats/dashboard/query_parser.ex +++ b/lib/plausible/stats/dashboard/query_parser.ex @@ -20,6 +20,8 @@ defmodule Plausible.Stats.Dashboard.QueryParser do {:ok, dimensions} <- ApiQueryParser.parse_dimensions(params["dimensions"]), {:ok, filters} <- ApiQueryParser.parse_filters(params["filters"]), {:ok, metrics} <- parse_metrics(params), + {:ok, order_by} <- ApiQueryParser.parse_order_by(params["order_by"]), + {:ok, pagination} <- ApiQueryParser.parse_pagination(params["pagination"]), {:ok, include} <- parse_include(params) do {:ok, ParsedQueryParams.new!(%{ @@ -28,6 +30,8 @@ defmodule Plausible.Stats.Dashboard.QueryParser do dimensions: dimensions, filters: filters, metrics: metrics, + order_by: order_by, + pagination: pagination, include: include, skip_goal_existence_check: true, now: Keyword.get(opts, :now) From 5044c1112460324831411f1cc0f1be1d7f8c4f63 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Sun, 19 Apr 2026 12:00:24 +0100 Subject: [PATCH 04/40] improve API v2 interface - instead of importing Metric from types/query-api.d.ts, define a Metric type in metrics.ts (including all internally available metrics) and use that as the single Metric type everywhere - add metric labelling logic in metrics.ts - move MetricValue type from fetch-main-graph.ts -> api.ts, and use it for Top Stats metric value as well --- assets/js/dashboard/api.ts | 43 +++- assets/js/dashboard/stats-query.ts | 2 +- .../dashboard/stats/graph/fetch-main-graph.ts | 12 +- .../stats/graph/fetch-top-stats.test.ts | 2 +- .../dashboard/stats/graph/fetch-top-stats.ts | 5 +- .../dashboard/stats/graph/main-graph-data.ts | 3 +- .../js/dashboard/stats/graph/main-graph.tsx | 10 +- .../dashboard/stats/graph/visitor-graph.tsx | 2 +- assets/js/dashboard/stats/metrics.test.ts | 236 ++++++++++++++++++ assets/js/dashboard/stats/metrics.ts | 124 ++++++++- .../dashboard/stats/reports/change-arrow.tsx | 2 +- .../stats/reports/metric-formatter.ts | 9 +- 12 files changed, 405 insertions(+), 45 deletions(-) create mode 100644 assets/js/dashboard/stats/metrics.test.ts diff --git a/assets/js/dashboard/api.ts b/assets/js/dashboard/api.ts index d51aa2ae8035..a77f88ecfc10 100644 --- a/assets/js/dashboard/api.ts +++ b/assets/js/dashboard/api.ts @@ -1,4 +1,4 @@ -import { Metric } from '../types/query-api' +import { Metric } from './stats/metrics' import { DashboardState } from './dashboard-state' import { PlausibleSite } from './site-context' import { StatsQuery } from './stats-query' @@ -9,18 +9,37 @@ import * as url from './util/url' let abortController = new AbortController() let SHARED_LINK_AUTH: null | string = null +export type RevenueMetricValue = { + short: string + value: number + long: string + currency: string +} + +export type MetricValue = null | number | RevenueMetricValue + +export type QueryResultQuery = { + metrics: Metric[] + date_range: [string, string] + comparison_date_range?: [string, string] | null +} + +export type QueryResultMeta = { + metric_warnings?: Record> + imports_included?: boolean + imports_skip_reason?: string +} + +export type QueryResultRow = { + metrics: Array + dimensions: Array + comparison?: { metrics: Array; change: Array } +} + export type QueryApiResponse = { - query: { - metrics: Metric[] - date_range: [string, string] - comparison_date_range: [string, string] - } - meta: Record - results: { - metrics: Array - dimensions: Array - comparison: { metrics: Array; change: Array } - }[] + query: QueryResultQuery + meta: QueryResultMeta + results: QueryResultRow[] } export class ApiError extends Error { diff --git a/assets/js/dashboard/stats-query.ts b/assets/js/dashboard/stats-query.ts index 0f356fdf5625..7631ff9194f9 100644 --- a/assets/js/dashboard/stats-query.ts +++ b/assets/js/dashboard/stats-query.ts @@ -1,4 +1,4 @@ -import { Metric } from '../types/query-api' +import { Metric } from './stats/metrics' import { DashboardState, Filter } from './dashboard-state' import { ComparisonMode, DashboardPeriod } from './dashboard-time-periods' import { formatISO } from './util/date' diff --git a/assets/js/dashboard/stats/graph/fetch-main-graph.ts b/assets/js/dashboard/stats/graph/fetch-main-graph.ts index b944f6ed272c..be2ac6883457 100644 --- a/assets/js/dashboard/stats/graph/fetch-main-graph.ts +++ b/assets/js/dashboard/stats/graph/fetch-main-graph.ts @@ -1,9 +1,10 @@ -import { Metric } from '../../../types/query-api' +import { Metric } from '../metrics' import { DashboardState } from '../../dashboard-state' import { DashboardPeriod } from '../../dashboard-time-periods' import { PlausibleSite } from '../../site-context' import { createStatsQuery, ReportParams } from '../../stats-query' import { isRealTimeDashboard } from '../../util/filters' +import { MetricValue } from '../../api' import * as api from '../../api' export function fetchMainGraph( @@ -35,20 +36,11 @@ export function fetchMainGraph( return api.stats(site, statsQuery) } -export type RevenueMetricValue = { - short: string - value: number - long: string - currency: string -} - export type ResultItem = { dimensions: [string] // one item metrics: MetricValues } -export type MetricValue = null | number | RevenueMetricValue - export type MetricValues = [MetricValue] // one item export type MainGraphResponse = { diff --git a/assets/js/dashboard/stats/graph/fetch-top-stats.test.ts b/assets/js/dashboard/stats/graph/fetch-top-stats.test.ts index 00cd269f07c9..d259e680e055 100644 --- a/assets/js/dashboard/stats/graph/fetch-top-stats.test.ts +++ b/assets/js/dashboard/stats/graph/fetch-top-stats.test.ts @@ -1,4 +1,4 @@ -import { Metric } from '../../../types/query-api' +import { Metric } from '../metrics' import { DashboardState, dashboardStateDefaultValue, diff --git a/assets/js/dashboard/stats/graph/fetch-top-stats.ts b/assets/js/dashboard/stats/graph/fetch-top-stats.ts index d8392f509551..d33b2331ba42 100644 --- a/assets/js/dashboard/stats/graph/fetch-top-stats.ts +++ b/assets/js/dashboard/stats/graph/fetch-top-stats.ts @@ -1,7 +1,6 @@ -import { Metric } from '../../../types/query-api' import * as api from '../../api' import { DashboardState } from '../../dashboard-state' -import { getMetricLabel } from '../metrics' +import { Metric, getMetricLabel } from '../metrics' import { ComparisonMode, DashboardPeriod, @@ -141,7 +140,7 @@ function constructTopStatsQuery( type TopStatItem = { metric: Metric - value: number + value: api.MetricValue name: string graphable: boolean change?: number diff --git a/assets/js/dashboard/stats/graph/main-graph-data.ts b/assets/js/dashboard/stats/graph/main-graph-data.ts index 9303d83ee35d..3f34a9f73ab8 100644 --- a/assets/js/dashboard/stats/graph/main-graph-data.ts +++ b/assets/js/dashboard/stats/graph/main-graph-data.ts @@ -1,4 +1,5 @@ -import { MainGraphResponse, MetricValue, ResultItem } from './fetch-main-graph' +import { MainGraphResponse, ResultItem } from './fetch-main-graph' +import { MetricValue } from '../../api' /** * Fills gaps in @see MainGraphResponse the series of `results` and `comparisonResults`. diff --git a/assets/js/dashboard/stats/graph/main-graph.tsx b/assets/js/dashboard/stats/graph/main-graph.tsx index d1a2d6f51a16..4cbdc179a7dd 100644 --- a/assets/js/dashboard/stats/graph/main-graph.tsx +++ b/assets/js/dashboard/stats/graph/main-graph.tsx @@ -19,16 +19,12 @@ import { } from '../../util/date' import classNames from 'classnames' import { ChangeArrow } from '../reports/change-arrow' -import { Metric } from '../../../types/query-api' import { useAppNavigate } from '../../navigation/use-app-navigate' import { Graph, PointerHandler, SeriesConfig } from '../../components/graph' import { useSiteContext, PlausibleSite } from '../../site-context' import { GraphTooltipWrapper } from '../../components/graph-tooltip' -import { - MainGraphResponse, - MetricValue, - RevenueMetricValue -} from './fetch-main-graph' +import { MetricValue, RevenueMetricValue } from '../../api' +import { MainGraphResponse } from './fetch-main-graph' import { remapAndFillData, getLineSegments, @@ -40,7 +36,7 @@ import { getFirstAndLastTimeLabels, MainGraphSeriesName } from './main-graph-data' -import { getMetricLabel } from '../metrics' +import { Metric, getMetricLabel } from '../metrics' import { useDashboardStateContext } from '../../dashboard-state-context' import { hasConversionGoalFilter } from '../../util/filters' import { Interval } from './intervals' diff --git a/assets/js/dashboard/stats/graph/visitor-graph.tsx b/assets/js/dashboard/stats/graph/visitor-graph.tsx index 2128ee71f5a0..97fa477dfa9c 100644 --- a/assets/js/dashboard/stats/graph/visitor-graph.tsx +++ b/assets/js/dashboard/stats/graph/visitor-graph.tsx @@ -6,7 +6,7 @@ import { fetchMainGraph } from './fetch-main-graph' import { useDashboardStateContext } from '../../dashboard-state-context' import { PlausibleSite, useSiteContext } from '../../site-context' import { useQuery, useQueryClient } from '@tanstack/react-query' -import { Metric } from '../../../types/query-api' +import { Metric } from '../metrics' import { DashboardPeriod } from '../../dashboard-time-periods' import { DashboardState } from '../../dashboard-state' import { getStaleTime } from '../../hooks/api-client' diff --git a/assets/js/dashboard/stats/metrics.test.ts b/assets/js/dashboard/stats/metrics.test.ts new file mode 100644 index 000000000000..4a17980b87f0 --- /dev/null +++ b/assets/js/dashboard/stats/metrics.test.ts @@ -0,0 +1,236 @@ +import { isSortable, getMetricLabel, getBreakdownMetricLabel } from './metrics' + +describe('isSortable', () => { + it('returns false for total_visitors', () => { + expect(isSortable('total_visitors')).toBe(false) + }) + + it.each(['visitors', 'bounce_rate', 'visit_duration', 'conversion_rate'])( + 'returns true for %s', + (metric) => { + expect(isSortable(metric as Parameters[0])).toBe(true) + } + ) +}) + +describe('getMetricLabel', () => { + it.each([ + ['visitors', false, 'Unique visitors'], + ['visitors', true, 'Unique conversions'], + ['events', false, 'Total events'], + ['events', true, 'Total conversions'], + ['visits', false, 'Total visits'], + ['pageviews', false, 'Total pageviews'], + ['views_per_visit', false, 'Views per visit'], + ['bounce_rate', false, 'Bounce rate'], + ['visit_duration', false, 'Visit duration'], + ['time_on_page', false, 'Time on page'], + ['scroll_depth', false, 'Scroll depth'], + ['conversion_rate', false, 'Conversion rate'], + ['total_revenue', false, 'Total revenue'], + ['average_revenue', false, 'Average revenue'], + ['percentage', false, 'Percentage'], + ['group_conversion_rate', false, 'Conversion rate'], + ['total_visitors', false, 'Total visitors'], + ['exit_rate', false, 'Exit rate'] + ] as const)( + '%s (hasConversionGoalFilter=%s) -> %s', + (metric, hasConversionGoalFilter, expected) => { + expect(getMetricLabel(metric, { hasConversionGoalFilter })).toBe(expected) + } + ) +}) + +describe('getBreakdownMetricLabel', () => { + const defaults = { hasConversionGoalFilter: false, isRealtime: false } + + describe('entry page dimension', () => { + const dimension = 'visit:entry_page' + + it('returns Unique entrances for visitors (no goal, not realtime)', () => { + expect( + getBreakdownMetricLabel('visitors', { + ...defaults, + dimensions: [dimension] + }) + ).toBe('Unique entrances') + }) + + it('returns Total entrances for visits (no goal, not realtime)', () => { + expect( + getBreakdownMetricLabel('visits', { + ...defaults, + dimensions: [dimension] + }) + ).toBe('Total entrances') + }) + + it('falls back to default label for visitors with conversion goal', () => { + expect( + getBreakdownMetricLabel('visitors', { + hasConversionGoalFilter: true, + isRealtime: false, + dimensions: [dimension] + }) + ).toBe('Conversions') + }) + + it('falls back to default label for visitors in realtime', () => { + expect( + getBreakdownMetricLabel('visitors', { + hasConversionGoalFilter: false, + isRealtime: true, + dimensions: [dimension] + }) + ).toBe('Current visitors') + }) + + it('falls back to default label for other metrics', () => { + expect( + getBreakdownMetricLabel('bounce_rate', { + ...defaults, + dimensions: [dimension] + }) + ).toBe('Bounce rate') + }) + }) + + describe('exit page dimension', () => { + const dimension = 'visit:exit_page' + + it('returns Unique exits for visitors (no goal, not realtime)', () => { + expect( + getBreakdownMetricLabel('visitors', { + ...defaults, + dimensions: [dimension] + }) + ).toBe('Unique exits') + }) + + it('returns Total exits for visits (no goal, not realtime)', () => { + expect( + getBreakdownMetricLabel('visits', { + ...defaults, + dimensions: [dimension] + }) + ).toBe('Total exits') + }) + + it('falls back to default label for visitors with conversion goal', () => { + expect( + getBreakdownMetricLabel('visitors', { + hasConversionGoalFilter: true, + isRealtime: false, + dimensions: [dimension] + }) + ).toBe('Conversions') + }) + + it('falls back to default label for visitors in realtime', () => { + expect( + getBreakdownMetricLabel('visitors', { + hasConversionGoalFilter: false, + isRealtime: true, + dimensions: [dimension] + }) + ).toBe('Current visitors') + }) + + it('falls back to default label for other metrics', () => { + expect( + getBreakdownMetricLabel('exit_rate', { + ...defaults, + dimensions: [dimension] + }) + ).toBe('Exit rate') + }) + }) + + describe('goal dimension', () => { + const dimension = 'event:goal' + + it('returns Uniques for visitors', () => { + expect( + getBreakdownMetricLabel('visitors', { + ...defaults, + dimensions: [dimension] + }) + ).toBe('Uniques') + }) + + it('returns Total for events', () => { + expect( + getBreakdownMetricLabel('events', { + ...defaults, + dimensions: [dimension] + }) + ).toBe('Total') + }) + + it('returns CR for conversion_rate', () => { + expect( + getBreakdownMetricLabel('conversion_rate', { + ...defaults, + dimensions: [dimension] + }) + ).toBe('CR') + }) + + it('falls back to default label for other metrics', () => { + expect( + getBreakdownMetricLabel('bounce_rate', { + ...defaults, + dimensions: [dimension] + }) + ).toBe('Bounce rate') + }) + }) + + describe('any other session dimension', () => { + const dimensions = ['visit:source'] + + it('returns Visitors for visitors (no goal, not realtime)', () => { + expect( + getBreakdownMetricLabel('visitors', { ...defaults, dimensions }) + ).toBe('Visitors') + }) + + it('returns Conversions for visitors with conversion goal', () => { + expect( + getBreakdownMetricLabel('visitors', { + hasConversionGoalFilter: true, + isRealtime: false, + dimensions + }) + ).toBe('Conversions') + }) + + it('returns Current visitors for visitors in realtime', () => { + expect( + getBreakdownMetricLabel('visitors', { + hasConversionGoalFilter: false, + isRealtime: true, + dimensions + }) + ).toBe('Current visitors') + }) + + it.each([ + ['group_conversion_rate', 'CR'], + ['conversion_rate', 'CR'], + ['pageviews', 'Pageviews'], + ['average_revenue', 'Average'], + ['total_revenue', 'Revenue'] + ] as const)('%s -> %s', (metric, expected) => { + expect(getBreakdownMetricLabel(metric, { ...defaults, dimensions })).toBe( + expected + ) + }) + + it('delegates to getMetricLabel for other metrics', () => { + expect( + getBreakdownMetricLabel('bounce_rate', { ...defaults, dimensions }) + ).toBe('Bounce rate') + }) + }) +}) diff --git a/assets/js/dashboard/stats/metrics.ts b/assets/js/dashboard/stats/metrics.ts index 2a66eff70e74..3096725f9ce5 100644 --- a/assets/js/dashboard/stats/metrics.ts +++ b/assets/js/dashboard/stats/metrics.ts @@ -1,4 +1,6 @@ -import { Metric } from '../../types/query-api' +import { Metric as PublicApiMetric } from '../../types/query-api' + +export type Metric = PublicApiMetric | 'total_visitors' | 'exit_rate' export const getMetricLabel = ( metric: Metric, @@ -33,5 +35,125 @@ export const getMetricLabel = ( return 'Percentage' case 'group_conversion_rate': return 'Conversion rate' + case 'total_visitors': + return 'Total visitors' + case 'exit_rate': + return 'Exit rate' + } +} + +export const getBreakdownMetricLabel = ( + metric: Metric, + { + hasConversionGoalFilter, + isRealtime, + dimensions + }: { + hasConversionGoalFilter: boolean + isRealtime: boolean + dimensions: string[] + } +): string => { + switch (dimensions[0]) { + case 'visit:entry_page': + return getEntryPagesBreakdownMetricLabel(metric, { + hasConversionGoalFilter, + isRealtime + }) + case 'visit:exit_page': + return getExitPagesBreakdownMetricLabel(metric, { + hasConversionGoalFilter, + isRealtime + }) + case 'event:goal': + return getConversionsBreakdownMetricLabel(metric) + default: + return getDefaultBreakdownMetricLabel(metric, { + hasConversionGoalFilter, + isRealtime + }) + } +} + +const getEntryPagesBreakdownMetricLabel = ( + metric: Metric, + { + hasConversionGoalFilter, + isRealtime + }: { hasConversionGoalFilter: boolean; isRealtime: boolean } +): string => { + if (metric === 'visitors' && !hasConversionGoalFilter && !isRealtime) { + return 'Unique entrances' + } + if (metric === 'visits' && !hasConversionGoalFilter && !isRealtime) { + return 'Total entrances' + } + + return getDefaultBreakdownMetricLabel(metric, { + hasConversionGoalFilter, + isRealtime + }) +} + +const getExitPagesBreakdownMetricLabel = ( + metric: Metric, + { + hasConversionGoalFilter, + isRealtime + }: { hasConversionGoalFilter: boolean; isRealtime: boolean } +): string => { + if (metric === 'visitors' && !hasConversionGoalFilter && !isRealtime) { + return 'Unique exits' + } + if (metric === 'visits' && !hasConversionGoalFilter && !isRealtime) { + return 'Total exits' + } + + return getDefaultBreakdownMetricLabel(metric, { + hasConversionGoalFilter, + isRealtime + }) +} + +const getConversionsBreakdownMetricLabel = (metric: Metric): string => { + switch (metric) { + case 'visitors': + return 'Uniques' + case 'events': + return 'Total' + default: + return getDefaultBreakdownMetricLabel(metric, { + hasConversionGoalFilter: false, + isRealtime: false + }) + } +} + +const getDefaultBreakdownMetricLabel = ( + metric: Metric, + { + hasConversionGoalFilter, + isRealtime + }: { hasConversionGoalFilter: boolean; isRealtime: boolean } +): string => { + switch (metric) { + case 'visitors': + return hasConversionGoalFilter + ? 'Conversions' + : isRealtime + ? 'Current visitors' + : 'Visitors' + case 'group_conversion_rate': + return 'CR' + case 'conversion_rate': + return 'CR' + case 'average_revenue': + return 'Average' + case 'total_revenue': + return 'Revenue' + case 'pageviews': + return 'Pageviews' + default: + return getMetricLabel(metric, { hasConversionGoalFilter }) } } diff --git a/assets/js/dashboard/stats/reports/change-arrow.tsx b/assets/js/dashboard/stats/reports/change-arrow.tsx index d6b8c4ccde74..c227a24e299d 100644 --- a/assets/js/dashboard/stats/reports/change-arrow.tsx +++ b/assets/js/dashboard/stats/reports/change-arrow.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { Metric } from '../../../types/query-api' +import { Metric } from '../metrics' import { numberShortFormatter } from '../../util/number-formatter' import { ArrowDownRightIcon, ArrowUpRightIcon } from '@heroicons/react/24/solid' import classNames from 'classnames' diff --git a/assets/js/dashboard/stats/reports/metric-formatter.ts b/assets/js/dashboard/stats/reports/metric-formatter.ts index 6367d2b39649..604f1cf3274e 100644 --- a/assets/js/dashboard/stats/reports/metric-formatter.ts +++ b/assets/js/dashboard/stats/reports/metric-formatter.ts @@ -1,4 +1,4 @@ -import { Metric } from '../../../types/query-api' +import { Metric } from '../metrics' import { formatMoneyShort, formatMoneyLong } from '../../util/money' import { numberShortFormatter, @@ -8,12 +8,7 @@ import { nullable } from '../../util/number-formatter' -export type FormattableMetric = - | Metric - | 'total_visitors' - | 'current_visitors' - | 'exit_rate' - | 'conversions' +export type FormattableMetric = Metric | 'current_visitors' | 'conversions' // eslint-disable-next-line @typescript-eslint/no-explicit-any export type ValueType = any From d4836d7335a4f8a9a8c3744841f6c3b224bf00db Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Sun, 19 Apr 2026 12:26:46 +0100 Subject: [PATCH 05/40] prepare StatsQuery for v2 breakdowns and add usePaginatedQueryAPI hook --- assets/js/dashboard/hooks/api-client.ts | 75 +++++++++++++++++++++++++ assets/js/dashboard/stats-query.ts | 26 ++++++++- assets/js/dashboard/stats/metrics.ts | 6 ++ 3 files changed, 105 insertions(+), 2 deletions(-) diff --git a/assets/js/dashboard/hooks/api-client.ts b/assets/js/dashboard/hooks/api-client.ts index 5b944c511761..59074126b32c 100644 --- a/assets/js/dashboard/hooks/api-client.ts +++ b/assets/js/dashboard/hooks/api-client.ts @@ -13,6 +13,8 @@ import { } from '../dashboard-time-periods' import { REALTIME_UPDATE_TIME_MS } from '../util/realtime-update-timer' import { Interval, validIntervals } from '../stats/graph/intervals' +import { PlausibleSite } from '../site-context' +import { StatsQuery } from '../stats-query' // define (in ms) when query API responses should become stale export const CACHE_TTL_REALTIME = REALTIME_UPDATE_TIME_MS @@ -32,6 +34,79 @@ type GetRequestParams = ( k: TKey ) => [DashboardState, Record] +/** + * Hook for paginated POST /api/stats/:domain/query requests. + * Pass pageSize to limit results (e.g. 9 for index views, default 100 for modals). + * Set enabled=false to defer fetching (e.g. until the component is visible). + */ +export function usePaginatedQueryAPI({ + site, + statsQuery, + afterFetchData, + afterFetchNextPage, + pageSize = PAGINATION_LIMIT, + enabled = true +}: { + site: PlausibleSite + statsQuery: StatsQuery + afterFetchData?: (response: api.QueryApiResponse) => void + afterFetchNextPage?: (response: api.QueryApiResponse) => void + pageSize?: number + enabled?: boolean +}) { + const queryClient = useQueryClient() + const dimensionKey = statsQuery.dimensions.join(',') + + useEffect(() => { + return () => { + const tanstackQueryFilters: QueryFilters = { + predicate: (query) => { + const key = query.queryKey[0] + return ( + typeof key === 'object' && + key !== null && + 'dimensions' in key && + (key as StatsQuery).dimensions.join(',') === dimensionKey + ) + } + } + queryClient.setQueriesData(tanstackQueryFilters, cleanToPageOne) + } + }, [queryClient, dimensionKey]) + + return useInfiniteQuery({ + queryKey: [statsQuery], + enabled, + queryFn: async ({ + pageParam + }): Promise => { + const response: api.QueryApiResponse = await api.stats(site, { + ...statsQuery, + pagination: { limit: pageSize, offset: pageParam as number } + } as StatsQuery) + + if (pageParam === 0 && typeof afterFetchData === 'function') { + afterFetchData(response) + } + if ( + (pageParam as number) > 0 && + typeof afterFetchNextPage === 'function' + ) { + afterFetchNextPage(response) + } + + return response.results + }, + getNextPageParam: (lastPageResults, _, lastPageParam) => { + return lastPageResults.length === pageSize + ? (lastPageParam as number) + pageSize + : null + }, + initialPageParam: 0, + placeholderData: (previousData) => previousData + }) +} + /** * Hook that fetches the first page from the defined GET endpoint on mount, * then subsequent pages when component calls fetchNextPage. diff --git a/assets/js/dashboard/stats-query.ts b/assets/js/dashboard/stats-query.ts index 7631ff9194f9..853f9f4baf33 100644 --- a/assets/js/dashboard/stats-query.ts +++ b/assets/js/dashboard/stats-query.ts @@ -1,9 +1,21 @@ import { Metric } from './stats/metrics' -import { DashboardState, Filter } from './dashboard-state' +import { + DashboardState, + FilterOperator, + FilterKey, + FilterClause +} from './dashboard-state' +import { OrderByEntry } from '../types/query-api' import { ComparisonMode, DashboardPeriod } from './dashboard-time-periods' import { formatISO } from './util/date' import { remapToApiFilters } from './util/filters' +export type FilterModifiers = { case_sensitive?: boolean } + +export type ApiFilter = + | [FilterOperator, FilterKey, FilterClause[]] + | [FilterOperator, FilterKey, FilterClause[], FilterModifiers] + type DateRange = DashboardPeriod | [string, string] type IncludeCompare = | ComparisonMode.previous_period @@ -26,15 +38,24 @@ export type ReportParams = { metrics: Metric[] dimensions?: string[] include?: Partial + order_by?: OrderByEntry[] } export type StatsQuery = { date_range: DateRange relative_date: string | null - filters: Filter[] + filters: ApiFilter[] dimensions: string[] metrics: Metric[] include: QueryInclude + order_by?: OrderByEntry[] | null +} + +export function addFilter( + statsQuery: StatsQuery, + filter: ApiFilter +): StatsQuery { + return { ...statsQuery, filters: [...statsQuery.filters, filter] } } export function createStatsQuery( @@ -47,6 +68,7 @@ export function createStatsQuery( dimensions: reportParams.dimensions || [], metrics: reportParams.metrics, filters: remapToApiFilters(dashboardState.filters), + order_by: reportParams.order_by || null, include: { imports: dashboardState.with_imported, imports_meta: reportParams.include?.imports_meta || false, diff --git a/assets/js/dashboard/stats/metrics.ts b/assets/js/dashboard/stats/metrics.ts index 3096725f9ce5..98341bda5006 100644 --- a/assets/js/dashboard/stats/metrics.ts +++ b/assets/js/dashboard/stats/metrics.ts @@ -2,6 +2,12 @@ import { Metric as PublicApiMetric } from '../../types/query-api' export type Metric = PublicApiMetric | 'total_visitors' | 'exit_rate' +const NOT_SORTABLE = ['total_visitors'] + +export const isSortable = (metric: Metric): boolean => { + return !NOT_SORTABLE.includes(metric) +} + export const getMetricLabel = ( metric: Metric, { hasConversionGoalFilter }: { hasConversionGoalFilter: boolean } From 7b87388f3d9aae78bc655cfdb99c49e1ae7f88fd Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Sun, 19 Apr 2026 12:39:19 +0100 Subject: [PATCH 06/40] create copies of current use-order-by.ts and tests This commit introduces use-order-by-legacy.ts and use-order-by-legacy.test.ts files as copies of the current modules. The intent is to make the diff easier to review -- all components currently using the hook will use the legacy variant instead, and the actual file will change into v2 in the following commit. --- .../js/dashboard/components/sort-button.tsx | 2 +- assets/js/dashboard/components/table.tsx | 2 +- .../hooks/use-order-by-legacy.test.ts | 163 ++++++++++++++ .../js/dashboard/hooks/use-order-by-legacy.ts | 200 ++++++++++++++++++ .../js/dashboard/hooks/use-order-by.test.ts | 2 +- .../stats/modals/breakdown-modal.tsx | 2 +- .../modals/devices/browser-versions-modal.js | 2 +- .../stats/modals/devices/browsers-modal.js | 2 +- .../operating-system-versions-modal.js | 2 +- .../modals/devices/operating-systems-modal.js | 2 +- .../stats/modals/devices/screen-sizes.js | 2 +- .../js/dashboard/stats/modals/entry-pages.js | 2 +- .../js/dashboard/stats/modals/exit-pages.js | 2 +- .../dashboard/stats/modals/locations-modal.js | 2 +- assets/js/dashboard/stats/modals/pages.js | 2 +- assets/js/dashboard/stats/modals/props.js | 2 +- .../stats/modals/referrer-drilldown.js | 2 +- assets/js/dashboard/stats/modals/sources.js | 2 +- 18 files changed, 379 insertions(+), 16 deletions(-) create mode 100644 assets/js/dashboard/hooks/use-order-by-legacy.test.ts create mode 100644 assets/js/dashboard/hooks/use-order-by-legacy.ts diff --git a/assets/js/dashboard/components/sort-button.tsx b/assets/js/dashboard/components/sort-button.tsx index 3ca98b298c4e..3c1e2c4952e7 100644 --- a/assets/js/dashboard/components/sort-button.tsx +++ b/assets/js/dashboard/components/sort-button.tsx @@ -1,5 +1,5 @@ import React, { ReactNode } from 'react' -import { cycleSortDirection, SortDirection } from '../hooks/use-order-by' +import { cycleSortDirection, SortDirection } from '../hooks/use-order-by-legacy' import classNames from 'classnames' export const SortButton = ({ diff --git a/assets/js/dashboard/components/table.tsx b/assets/js/dashboard/components/table.tsx index ba9116193e0b..7460fb26a2d1 100644 --- a/assets/js/dashboard/components/table.tsx +++ b/assets/js/dashboard/components/table.tsx @@ -1,6 +1,6 @@ import classNames from 'classnames' import React, { ReactNode } from 'react' -import { SortDirection } from '../hooks/use-order-by' +import { SortDirection } from '../hooks/use-order-by-legacy' import { SortButton } from './sort-button' import { Tooltip } from '../util/tooltip' diff --git a/assets/js/dashboard/hooks/use-order-by-legacy.test.ts b/assets/js/dashboard/hooks/use-order-by-legacy.test.ts new file mode 100644 index 000000000000..8e5a1a8cbfc1 --- /dev/null +++ b/assets/js/dashboard/hooks/use-order-by-legacy.test.ts @@ -0,0 +1,163 @@ +import { Metric } from '../stats/reports/metrics' +import { + OrderBy, + SortDirection, + cycleSortDirection, + findOrderIndex, + getOrderByStorageKey, + getStoredOrderBy, + maybeStoreOrderBy, + rearrangeOrderBy, + validateOrderBy +} from './use-order-by-legacy' + +describe(`${findOrderIndex.name}`, () => { + /* prettier-ignore */ + const cases: [OrderBy, Pick, number][] = [ + [[], { key: 'anything' }, -1], + [[['visitors', SortDirection.asc]], { key: 'anything' }, -1], + [[['bounce_rate', SortDirection.desc], ['visitors', SortDirection.asc]], {key: 'bounce_rate'}, 0], + [[['bounce_rate', SortDirection.desc], ['visitors', SortDirection.asc]], {key: 'visitors'}, 1] + ] + + test.each(cases)( + `[%#] in order by %p, the index of metric %p is %p`, + (orderBy, metric, expectedIndex) => { + expect(findOrderIndex(orderBy, metric)).toEqual(expectedIndex) + } + ) +}) + +describe(`${cycleSortDirection.name}`, () => { + test.each([ + [ + null, + { + direction: SortDirection.desc, + hint: 'Press to sort column in descending order' + } + ], + [ + SortDirection.desc, + { + direction: SortDirection.asc, + hint: 'Press to sort column in ascending order' + } + ], + [ + SortDirection.asc, + { + direction: SortDirection.desc, + hint: 'Press to sort column in descending order' + } + ] + ])( + 'for current direction %p returns %p', + (currentDirection, expectedOutput) => { + expect(cycleSortDirection(currentDirection)).toEqual(expectedOutput) + } + ) +}) + +describe(`${rearrangeOrderBy.name}`, () => { + const cases: [Pick, OrderBy, OrderBy][] = [ + [ + { key: 'visitors' }, + [['visitors', SortDirection.asc]], + [['visitors', SortDirection.desc]] + ], + [ + { key: 'visitors' }, + [['visitors', SortDirection.desc]], + [['visitors', SortDirection.asc]] + ], + [ + { key: 'visit_duration' }, + [['visitors', SortDirection.asc]], + [['visit_duration', SortDirection.desc]] + ] + ] + it.each(cases)( + `[%#] clicking on %p yields expected order`, + (metric, currentOrderBy, expectedOrderBy) => { + expect(rearrangeOrderBy(currentOrderBy, metric)).toEqual(expectedOrderBy) + } + ) +}) + +describe(`${validateOrderBy.name}`, () => { + test.each([ + [false, '', []], + [false, [], []], + [false, [['a']], [{ key: 'a' }]], + [false, [['a', 'b']], [{ key: 'a' }]], + [ + false, + [ + ['a', 'desc'], + ['a', 'asc'] + ], + [{ key: 'a' }] + ], + [true, [['a', 'desc']], [{ key: 'a' }]] + ])( + '[%#] returns %p given input %p and sortable metrics %p', + (expected, input, sortableMetrics) => { + expect(validateOrderBy(input, sortableMetrics)).toBe(expected) + } + ) +}) + +describe(`storing detailed report preferred order`, () => { + const domain = 'any-domain' + const reportInfo = { dimensionLabel: 'Goal' } + + it('does not store invalid value', () => { + maybeStoreOrderBy({ + orderBy: [['foo', SortDirection.desc]], + domain, + reportInfo, + metrics: [{ key: 'foo', sortable: false }] + }) + expect(localStorage.getItem(getOrderByStorageKey(domain, reportInfo))).toBe( + null + ) + }) + + it('falls back to fallbackValue if metric has become unsortable between storing and retrieving', () => { + maybeStoreOrderBy({ + orderBy: [['c', SortDirection.desc]], + domain, + reportInfo, + metrics: [{ key: 'c', sortable: true }] + }) + const inStorage = localStorage.getItem( + getOrderByStorageKey(domain, reportInfo) + ) + expect(inStorage).toBe('[["c","desc"]]') + expect( + getStoredOrderBy({ + domain, + reportInfo, + metrics: [{ key: 'c', sortable: false }], + fallbackValue: [['visitors', SortDirection.desc]] + }) + ).toEqual([['visitors', SortDirection.desc]]) + }) + + it('retrieves stored value correctly', () => { + const input = [['any-column', SortDirection.asc]] + localStorage.setItem( + getOrderByStorageKey(domain, reportInfo), + JSON.stringify(input) + ) + expect( + getStoredOrderBy({ + domain, + reportInfo, + metrics: [{ key: 'any-column', sortable: true }], + fallbackValue: [['visitors', SortDirection.desc]] + }) + ).toEqual(input) + }) +}) diff --git a/assets/js/dashboard/hooks/use-order-by-legacy.ts b/assets/js/dashboard/hooks/use-order-by-legacy.ts new file mode 100644 index 000000000000..28bde044af92 --- /dev/null +++ b/assets/js/dashboard/hooks/use-order-by-legacy.ts @@ -0,0 +1,200 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { Metric } from '../stats/reports/metrics' +import { getDomainScopedStorageKey, getItem, setItem } from '../util/storage' +import { useSiteContext } from '../site-context' +import { ReportInfo } from '../stats/modals/breakdown-modal' + +export enum SortDirection { + asc = 'asc', + desc = 'desc' +} + +export type Order = [Metric['key'], SortDirection] + +export type OrderBy = Order[] + +export const getSortDirectionLabel = (sortDirection: SortDirection): string => + ({ + [SortDirection.asc]: 'Sorted in ascending order', + [SortDirection.desc]: 'Sorted in descending order' + })[sortDirection] + +export function useOrderBy({ + metrics, + defaultOrderBy +}: { + metrics: Pick[] + defaultOrderBy: OrderBy +}) { + const [orderBy, setOrderBy] = useState([]) + const orderByDictionary: Record = useMemo( + () => + orderBy.length + ? Object.fromEntries(orderBy) + : Object.fromEntries(defaultOrderBy), + [orderBy, defaultOrderBy] + ) + + const toggleSortByMetric = useCallback( + (metric: Pick) => { + if (!metrics.find(({ key }) => key === metric.key)) { + return + } + setOrderBy((currentOrderBy) => + rearrangeOrderBy( + currentOrderBy.length ? currentOrderBy : defaultOrderBy, + metric + ) + ) + }, + [metrics, defaultOrderBy] + ) + + return { + orderBy: orderBy.length ? orderBy : defaultOrderBy, + orderByDictionary, + toggleSortByMetric + } +} + +export function cycleSortDirection( + currentSortDirection: SortDirection | null +): { direction: SortDirection; hint: string } { + if (currentSortDirection === SortDirection.desc) { + return { + direction: SortDirection.asc, + hint: 'Press to sort column in ascending order' + } + } + + return { + direction: SortDirection.desc, + hint: 'Press to sort column in descending order' + } +} + +export function findOrderIndex(orderBy: OrderBy, metric: Pick) { + return orderBy.findIndex(([metricKey]) => metricKey === metric.key) +} + +export function rearrangeOrderBy( + currentOrderBy: OrderBy, + metric: Pick +): OrderBy { + const orderIndex = findOrderIndex(currentOrderBy, metric) + if (orderIndex < 0) { + const sortDirection = cycleSortDirection(null).direction as SortDirection + return [[metric.key, sortDirection]] + } + const previousOrder = currentOrderBy[orderIndex] + const sortDirection = cycleSortDirection(previousOrder[1]).direction + if (sortDirection === null) { + return [] + } + return [[metric.key, sortDirection]] +} + +export function getOrderByStorageKey( + domain: string, + reportInfo: Pick +) { + const storageKey = getDomainScopedStorageKey( + `order_${reportInfo.dimensionLabel}_by`, + domain + ) + return storageKey +} + +export function validateOrderBy( + orderBy: unknown, + metrics: Pick[] +): orderBy is OrderBy { + if (!Array.isArray(orderBy)) { + return false + } + if (orderBy.length !== 1) { + return false + } + if (!Array.isArray(orderBy[0])) { + return false + } + if ( + orderBy[0].length === 2 && + metrics.findIndex((m) => m.key === orderBy[0][0]) > -1 && + [SortDirection.asc, SortDirection.desc].includes(orderBy[0][1]) + ) { + return true + } + return false +} + +export function getStoredOrderBy({ + domain, + reportInfo, + metrics, + fallbackValue +}: { + domain: string + reportInfo: Pick + metrics: Pick[] + fallbackValue: OrderBy +}): OrderBy { + try { + const storedItem = getItem(getOrderByStorageKey(domain, reportInfo)) + const parsed = JSON.parse(storedItem) + if ( + validateOrderBy( + parsed, + metrics.filter((m) => m.sortable) + ) + ) { + return parsed + } else { + throw new Error('Invalid stored order_by value') + } + } catch (_e) { + return fallbackValue + } +} + +export function maybeStoreOrderBy({ + domain, + reportInfo, + metrics, + orderBy +}: { + domain: string + reportInfo: Pick + metrics: Pick[] + orderBy: OrderBy +}) { + if ( + validateOrderBy( + orderBy, + metrics.filter((m) => m.sortable) + ) + ) { + setItem(getOrderByStorageKey(domain, reportInfo), JSON.stringify(orderBy)) + } +} + +export function useRememberOrderBy({ + effectiveOrderBy, + metrics, + reportInfo +}: { + effectiveOrderBy: OrderBy + metrics: Pick[] + reportInfo: Pick +}) { + const site = useSiteContext() + + useEffect(() => { + maybeStoreOrderBy({ + domain: site.domain, + metrics, + reportInfo, + orderBy: effectiveOrderBy + }) + }, [site, reportInfo, effectiveOrderBy, metrics]) +} diff --git a/assets/js/dashboard/hooks/use-order-by.test.ts b/assets/js/dashboard/hooks/use-order-by.test.ts index 40e63fcf42f7..8e5a1a8cbfc1 100644 --- a/assets/js/dashboard/hooks/use-order-by.test.ts +++ b/assets/js/dashboard/hooks/use-order-by.test.ts @@ -9,7 +9,7 @@ import { maybeStoreOrderBy, rearrangeOrderBy, validateOrderBy -} from './use-order-by' +} from './use-order-by-legacy' describe(`${findOrderIndex.name}`, () => { /* prettier-ignore */ diff --git a/assets/js/dashboard/stats/modals/breakdown-modal.tsx b/assets/js/dashboard/stats/modals/breakdown-modal.tsx index 04d3e4f65adf..7852b15cf558 100644 --- a/assets/js/dashboard/stats/modals/breakdown-modal.tsx +++ b/assets/js/dashboard/stats/modals/breakdown-modal.tsx @@ -9,7 +9,7 @@ import { OrderBy, useOrderBy, useRememberOrderBy -} from '../../hooks/use-order-by' +} from '../../hooks/use-order-by-legacy' import { Metric } from '../reports/metrics' import * as metricsModule from '../reports/metrics' import { BreakdownResultMeta, DashboardState } from '../../dashboard-state' diff --git a/assets/js/dashboard/stats/modals/devices/browser-versions-modal.js b/assets/js/dashboard/stats/modals/devices/browser-versions-modal.js index 460a04aaca76..202f501f31b2 100644 --- a/assets/js/dashboard/stats/modals/devices/browser-versions-modal.js +++ b/assets/js/dashboard/stats/modals/devices/browser-versions-modal.js @@ -7,7 +7,7 @@ import { useDashboardStateContext } from '../../../dashboard-state-context' import { useSiteContext } from '../../../site-context' import { browserIconFor } from '../../devices' import chooseMetrics from './choose-metrics' -import { SortDirection } from '../../../hooks/use-order-by' +import { SortDirection } from '../../../hooks/use-order-by-legacy' function BrowserVersionsModal() { const { dashboardState } = useDashboardStateContext() diff --git a/assets/js/dashboard/stats/modals/devices/browsers-modal.js b/assets/js/dashboard/stats/modals/devices/browsers-modal.js index 7cbc87d7dc26..bb836a8c76aa 100644 --- a/assets/js/dashboard/stats/modals/devices/browsers-modal.js +++ b/assets/js/dashboard/stats/modals/devices/browsers-modal.js @@ -7,7 +7,7 @@ import { useDashboardStateContext } from '../../../dashboard-state-context' import { useSiteContext } from '../../../site-context' import { browserIconFor } from '../../devices' import chooseMetrics from './choose-metrics' -import { SortDirection } from '../../../hooks/use-order-by' +import { SortDirection } from '../../../hooks/use-order-by-legacy' function BrowsersModal() { const { dashboardState } = useDashboardStateContext() diff --git a/assets/js/dashboard/stats/modals/devices/operating-system-versions-modal.js b/assets/js/dashboard/stats/modals/devices/operating-system-versions-modal.js index ca3ae1919333..1e43bcf850e3 100644 --- a/assets/js/dashboard/stats/modals/devices/operating-system-versions-modal.js +++ b/assets/js/dashboard/stats/modals/devices/operating-system-versions-modal.js @@ -7,7 +7,7 @@ import { useDashboardStateContext } from '../../../dashboard-state-context' import { useSiteContext } from '../../../site-context' import { osIconFor } from '../../devices' import chooseMetrics from './choose-metrics' -import { SortDirection } from '../../../hooks/use-order-by' +import { SortDirection } from '../../../hooks/use-order-by-legacy' function OperatingSystemVersionsModal() { const { dashboardState } = useDashboardStateContext() diff --git a/assets/js/dashboard/stats/modals/devices/operating-systems-modal.js b/assets/js/dashboard/stats/modals/devices/operating-systems-modal.js index d5f36b2f8100..31ba8e06b179 100644 --- a/assets/js/dashboard/stats/modals/devices/operating-systems-modal.js +++ b/assets/js/dashboard/stats/modals/devices/operating-systems-modal.js @@ -7,7 +7,7 @@ import { useDashboardStateContext } from '../../../dashboard-state-context' import { useSiteContext } from '../../../site-context' import { osIconFor } from '../../devices' import chooseMetrics from './choose-metrics' -import { SortDirection } from '../../../hooks/use-order-by' +import { SortDirection } from '../../../hooks/use-order-by-legacy' function OperatingSystemsModal() { const { dashboardState } = useDashboardStateContext() diff --git a/assets/js/dashboard/stats/modals/devices/screen-sizes.js b/assets/js/dashboard/stats/modals/devices/screen-sizes.js index 79c3f5da6d54..9d75d1c277b6 100644 --- a/assets/js/dashboard/stats/modals/devices/screen-sizes.js +++ b/assets/js/dashboard/stats/modals/devices/screen-sizes.js @@ -6,7 +6,7 @@ import { useDashboardStateContext } from '../../../dashboard-state-context' import { useSiteContext } from '../../../site-context' import { screenSizeIconFor } from '../../devices' import chooseMetrics from './choose-metrics' -import { SortDirection } from '../../../hooks/use-order-by' +import { SortDirection } from '../../../hooks/use-order-by-legacy' function ScreenSizesModal() { const { dashboardState } = useDashboardStateContext() diff --git a/assets/js/dashboard/stats/modals/entry-pages.js b/assets/js/dashboard/stats/modals/entry-pages.js index a443248ca9da..43eff1a232c3 100644 --- a/assets/js/dashboard/stats/modals/entry-pages.js +++ b/assets/js/dashboard/stats/modals/entry-pages.js @@ -10,7 +10,7 @@ import * as metrics from '../reports/metrics' import * as url from '../../util/url' import { useDashboardStateContext } from '../../dashboard-state-context' import { useSiteContext } from '../../site-context' -import { SortDirection } from '../../hooks/use-order-by' +import { SortDirection } from '../../hooks/use-order-by-legacy' function EntryPagesModal() { const { dashboardState } = useDashboardStateContext() diff --git a/assets/js/dashboard/stats/modals/exit-pages.js b/assets/js/dashboard/stats/modals/exit-pages.js index 84a5b6f59b8f..75b4cd00c2df 100644 --- a/assets/js/dashboard/stats/modals/exit-pages.js +++ b/assets/js/dashboard/stats/modals/exit-pages.js @@ -10,7 +10,7 @@ import * as metrics from '../reports/metrics' import * as url from '../../util/url' import { useDashboardStateContext } from '../../dashboard-state-context' import { useSiteContext } from '../../site-context' -import { SortDirection } from '../../hooks/use-order-by' +import { SortDirection } from '../../hooks/use-order-by-legacy' function ExitPagesModal() { const { dashboardState } = useDashboardStateContext() diff --git a/assets/js/dashboard/stats/modals/locations-modal.js b/assets/js/dashboard/stats/modals/locations-modal.js index ec3a5c5548cf..56a984c7fa5f 100644 --- a/assets/js/dashboard/stats/modals/locations-modal.js +++ b/assets/js/dashboard/stats/modals/locations-modal.js @@ -11,7 +11,7 @@ import * as url from '../../util/url' import { useDashboardStateContext } from '../../dashboard-state-context' import { useSiteContext } from '../../site-context' import { addFilter, revenueAvailable } from '../../dashboard-state' -import { SortDirection } from '../../hooks/use-order-by' +import { SortDirection } from '../../hooks/use-order-by-legacy' const VIEWS = { countries: { diff --git a/assets/js/dashboard/stats/modals/pages.js b/assets/js/dashboard/stats/modals/pages.js index b93ab5869e16..0cadd154336a 100644 --- a/assets/js/dashboard/stats/modals/pages.js +++ b/assets/js/dashboard/stats/modals/pages.js @@ -10,7 +10,7 @@ import * as metrics from '../reports/metrics' import * as url from '../../util/url' import { useDashboardStateContext } from '../../dashboard-state-context' import { useSiteContext } from '../../site-context' -import { SortDirection } from '../../hooks/use-order-by' +import { SortDirection } from '../../hooks/use-order-by-legacy' function PagesModal() { const { dashboardState } = useDashboardStateContext() diff --git a/assets/js/dashboard/stats/modals/props.js b/assets/js/dashboard/stats/modals/props.js index 8dcc8e6e0b27..bc1361928d4d 100644 --- a/assets/js/dashboard/stats/modals/props.js +++ b/assets/js/dashboard/stats/modals/props.js @@ -14,7 +14,7 @@ import * as metrics from '../reports/metrics' import * as url from '../../util/url' import { useDashboardStateContext } from '../../dashboard-state-context' import { useSiteContext } from '../../site-context' -import { SortDirection } from '../../hooks/use-order-by' +import { SortDirection } from '../../hooks/use-order-by-legacy' function PropsModal() { const { dashboardState } = useDashboardStateContext() diff --git a/assets/js/dashboard/stats/modals/referrer-drilldown.js b/assets/js/dashboard/stats/modals/referrer-drilldown.js index 8266a6fc5466..44239b677e77 100644 --- a/assets/js/dashboard/stats/modals/referrer-drilldown.js +++ b/assets/js/dashboard/stats/modals/referrer-drilldown.js @@ -12,7 +12,7 @@ import * as url from '../../util/url' import { addFilter, revenueAvailable } from '../../dashboard-state' import { useDashboardStateContext } from '../../dashboard-state-context' import { useSiteContext } from '../../site-context' -import { SortDirection } from '../../hooks/use-order-by' +import { SortDirection } from '../../hooks/use-order-by-legacy' import { SourceFavicon } from '../sources/source-favicon' function ReferrerDrilldownModal() { diff --git a/assets/js/dashboard/stats/modals/sources.js b/assets/js/dashboard/stats/modals/sources.js index b52b8e20e2ab..fa3ec2b274da 100644 --- a/assets/js/dashboard/stats/modals/sources.js +++ b/assets/js/dashboard/stats/modals/sources.js @@ -10,7 +10,7 @@ import * as url from '../../util/url' import { addFilter, revenueAvailable } from '../../dashboard-state' import { useDashboardStateContext } from '../../dashboard-state-context' import { useSiteContext } from '../../site-context' -import { SortDirection } from '../../hooks/use-order-by' +import { SortDirection } from '../../hooks/use-order-by-legacy' import { SourceFavicon } from '../sources/source-favicon' const VIEWS = { From c65713c521aeeeb795b376b970506479322cd5d3 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Sun, 19 Apr 2026 12:59:16 +0100 Subject: [PATCH 07/40] use-order-by v2 --- .../js/dashboard/hooks/use-order-by.test.ts | 52 +++++++++---------- assets/js/dashboard/hooks/use-order-by.ts | 40 +++++++------- 2 files changed, 46 insertions(+), 46 deletions(-) diff --git a/assets/js/dashboard/hooks/use-order-by.test.ts b/assets/js/dashboard/hooks/use-order-by.test.ts index 8e5a1a8cbfc1..f555abe058b6 100644 --- a/assets/js/dashboard/hooks/use-order-by.test.ts +++ b/assets/js/dashboard/hooks/use-order-by.test.ts @@ -1,4 +1,4 @@ -import { Metric } from '../stats/reports/metrics' +import { Metric } from '../stats/metrics' import { OrderBy, SortDirection, @@ -9,15 +9,15 @@ import { maybeStoreOrderBy, rearrangeOrderBy, validateOrderBy -} from './use-order-by-legacy' +} from './use-order-by' describe(`${findOrderIndex.name}`, () => { /* prettier-ignore */ - const cases: [OrderBy, Pick, number][] = [ - [[], { key: 'anything' }, -1], - [[['visitors', SortDirection.asc]], { key: 'anything' }, -1], - [[['bounce_rate', SortDirection.desc], ['visitors', SortDirection.asc]], {key: 'bounce_rate'}, 0], - [[['bounce_rate', SortDirection.desc], ['visitors', SortDirection.asc]], {key: 'visitors'}, 1] + const cases: [OrderBy, Metric, number][] = [ + [[], 'visitors', -1], + [[['visitors', SortDirection.asc]], 'bounce_rate', -1], + [[['bounce_rate', SortDirection.desc], ['visitors', SortDirection.asc]], 'bounce_rate', 0], + [[['bounce_rate', SortDirection.desc], ['visitors', SortDirection.asc]], 'visitors', 1] ] test.each(cases)( @@ -60,19 +60,19 @@ describe(`${cycleSortDirection.name}`, () => { }) describe(`${rearrangeOrderBy.name}`, () => { - const cases: [Pick, OrderBy, OrderBy][] = [ + const cases: [Metric, OrderBy, OrderBy][] = [ [ - { key: 'visitors' }, + 'visitors', [['visitors', SortDirection.asc]], [['visitors', SortDirection.desc]] ], [ - { key: 'visitors' }, + 'visitors', [['visitors', SortDirection.desc]], [['visitors', SortDirection.asc]] ], [ - { key: 'visit_duration' }, + 'visit_duration', [['visitors', SortDirection.asc]], [['visit_duration', SortDirection.desc]] ] @@ -89,21 +89,21 @@ describe(`${validateOrderBy.name}`, () => { test.each([ [false, '', []], [false, [], []], - [false, [['a']], [{ key: 'a' }]], - [false, [['a', 'b']], [{ key: 'a' }]], + [false, [['visitors']], ['visitors']], + [false, [['visitors', 'b']], ['visitors']], [ false, [ - ['a', 'desc'], - ['a', 'asc'] + ['visitors', 'desc'], + ['visitors', 'asc'] ], - [{ key: 'a' }] + ['visitors'] ], - [true, [['a', 'desc']], [{ key: 'a' }]] + [true, [['visitors', 'desc']], ['visitors']] ])( '[%#] returns %p given input %p and sortable metrics %p', (expected, input, sortableMetrics) => { - expect(validateOrderBy(input, sortableMetrics)).toBe(expected) + expect(validateOrderBy(input, sortableMetrics as Metric[])).toBe(expected) } ) }) @@ -114,10 +114,10 @@ describe(`storing detailed report preferred order`, () => { it('does not store invalid value', () => { maybeStoreOrderBy({ - orderBy: [['foo', SortDirection.desc]], + orderBy: [['total_visitors', SortDirection.desc]], domain, reportInfo, - metrics: [{ key: 'foo', sortable: false }] + metrics: ['total_visitors'] }) expect(localStorage.getItem(getOrderByStorageKey(domain, reportInfo))).toBe( null @@ -126,27 +126,27 @@ describe(`storing detailed report preferred order`, () => { it('falls back to fallbackValue if metric has become unsortable between storing and retrieving', () => { maybeStoreOrderBy({ - orderBy: [['c', SortDirection.desc]], + orderBy: [['visitors', SortDirection.desc]], domain, reportInfo, - metrics: [{ key: 'c', sortable: true }] + metrics: ['visitors'] }) const inStorage = localStorage.getItem( getOrderByStorageKey(domain, reportInfo) ) - expect(inStorage).toBe('[["c","desc"]]') + expect(inStorage).toBe('[["visitors","desc"]]') expect( getStoredOrderBy({ domain, reportInfo, - metrics: [{ key: 'c', sortable: false }], + metrics: ['total_visitors'], fallbackValue: [['visitors', SortDirection.desc]] }) ).toEqual([['visitors', SortDirection.desc]]) }) it('retrieves stored value correctly', () => { - const input = [['any-column', SortDirection.asc]] + const input: OrderBy = [['visitors', SortDirection.asc]] localStorage.setItem( getOrderByStorageKey(domain, reportInfo), JSON.stringify(input) @@ -155,7 +155,7 @@ describe(`storing detailed report preferred order`, () => { getStoredOrderBy({ domain, reportInfo, - metrics: [{ key: 'any-column', sortable: true }], + metrics: ['visitors'], fallbackValue: [['visitors', SortDirection.desc]] }) ).toEqual(input) diff --git a/assets/js/dashboard/hooks/use-order-by.ts b/assets/js/dashboard/hooks/use-order-by.ts index 28bde044af92..cf4453187d8a 100644 --- a/assets/js/dashboard/hooks/use-order-by.ts +++ b/assets/js/dashboard/hooks/use-order-by.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useState } from 'react' -import { Metric } from '../stats/reports/metrics' +import { isSortable, Metric } from '../stats/metrics' import { getDomainScopedStorageKey, getItem, setItem } from '../util/storage' import { useSiteContext } from '../site-context' import { ReportInfo } from '../stats/modals/breakdown-modal' @@ -9,7 +9,7 @@ export enum SortDirection { desc = 'desc' } -export type Order = [Metric['key'], SortDirection] +export type Order = [Metric, SortDirection] export type OrderBy = Order[] @@ -23,21 +23,21 @@ export function useOrderBy({ metrics, defaultOrderBy }: { - metrics: Pick[] + metrics: Metric[] defaultOrderBy: OrderBy }) { const [orderBy, setOrderBy] = useState([]) - const orderByDictionary: Record = useMemo( + const orderByDictionary = useMemo( () => - orderBy.length + (orderBy.length ? Object.fromEntries(orderBy) - : Object.fromEntries(defaultOrderBy), + : Object.fromEntries(defaultOrderBy)) as Record, [orderBy, defaultOrderBy] ) const toggleSortByMetric = useCallback( - (metric: Pick) => { - if (!metrics.find(({ key }) => key === metric.key)) { + (metric: Metric) => { + if (!metrics.find((m) => m === metric)) { return } setOrderBy((currentOrderBy) => @@ -73,25 +73,25 @@ export function cycleSortDirection( } } -export function findOrderIndex(orderBy: OrderBy, metric: Pick) { - return orderBy.findIndex(([metricKey]) => metricKey === metric.key) +export function findOrderIndex(orderBy: OrderBy, metric: Metric) { + return orderBy.findIndex(([m]) => m === metric) } export function rearrangeOrderBy( currentOrderBy: OrderBy, - metric: Pick + metric: Metric ): OrderBy { const orderIndex = findOrderIndex(currentOrderBy, metric) if (orderIndex < 0) { const sortDirection = cycleSortDirection(null).direction as SortDirection - return [[metric.key, sortDirection]] + return [[metric, sortDirection]] } const previousOrder = currentOrderBy[orderIndex] const sortDirection = cycleSortDirection(previousOrder[1]).direction if (sortDirection === null) { return [] } - return [[metric.key, sortDirection]] + return [[metric, sortDirection]] } export function getOrderByStorageKey( @@ -107,7 +107,7 @@ export function getOrderByStorageKey( export function validateOrderBy( orderBy: unknown, - metrics: Pick[] + metrics: Metric[] ): orderBy is OrderBy { if (!Array.isArray(orderBy)) { return false @@ -120,7 +120,7 @@ export function validateOrderBy( } if ( orderBy[0].length === 2 && - metrics.findIndex((m) => m.key === orderBy[0][0]) > -1 && + metrics.findIndex((m) => m === orderBy[0][0]) > -1 && [SortDirection.asc, SortDirection.desc].includes(orderBy[0][1]) ) { return true @@ -136,7 +136,7 @@ export function getStoredOrderBy({ }: { domain: string reportInfo: Pick - metrics: Pick[] + metrics: Metric[] fallbackValue: OrderBy }): OrderBy { try { @@ -145,7 +145,7 @@ export function getStoredOrderBy({ if ( validateOrderBy( parsed, - metrics.filter((m) => m.sortable) + metrics.filter((m) => isSortable(m)) ) ) { return parsed @@ -165,13 +165,13 @@ export function maybeStoreOrderBy({ }: { domain: string reportInfo: Pick - metrics: Pick[] + metrics: Metric[] orderBy: OrderBy }) { if ( validateOrderBy( orderBy, - metrics.filter((m) => m.sortable) + metrics.filter((m) => isSortable(m)) ) ) { setItem(getOrderByStorageKey(domain, reportInfo), JSON.stringify(orderBy)) @@ -184,7 +184,7 @@ export function useRememberOrderBy({ reportInfo }: { effectiveOrderBy: OrderBy - metrics: Pick[] + metrics: Metric[] reportInfo: Pick }) { const site = useSiteContext() From 0fd1131c93735142bb0317e33c99206be75a7eff Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Sun, 19 Apr 2026 17:50:29 +0100 Subject: [PATCH 08/40] Create legacy copies of breakdown components * breakdown-modal.tsx -> breakdown-modal-legacy.tsx * breakdown-table.tsx -> breakdown-table-legacy.tsx * table.tsx -> table-legacy.tsx ...and use the legacy variants everywhere. The existing files will be transformed into v2 in the following commit. --- .../js/dashboard/components/table-legacy.tsx | 216 ++++++++++++++ .../js/dashboard/hooks/use-order-by-legacy.ts | 2 +- assets/js/dashboard/hooks/use-order-by.ts | 2 +- .../stats/modals/breakdown-modal-legacy.tsx | 273 ++++++++++++++++++ .../stats/modals/breakdown-modal.tsx | 4 +- .../stats/modals/breakdown-table-legacy.tsx | 134 +++++++++ .../stats/modals/breakdown-table.tsx | 2 +- .../js/dashboard/stats/modals/conversions.js | 2 +- .../modals/devices/browser-versions-modal.js | 2 +- .../stats/modals/devices/browsers-modal.js | 2 +- .../operating-system-versions-modal.js | 2 +- .../modals/devices/operating-systems-modal.js | 2 +- .../stats/modals/devices/screen-sizes.js | 2 +- .../js/dashboard/stats/modals/entry-pages.js | 2 +- .../js/dashboard/stats/modals/exit-pages.js | 2 +- .../stats/modals/google-keywords.tsx | 4 +- .../dashboard/stats/modals/locations-modal.js | 2 +- assets/js/dashboard/stats/modals/pages.js | 2 +- assets/js/dashboard/stats/modals/props.js | 2 +- .../stats/modals/referrer-drilldown.js | 2 +- assets/js/dashboard/stats/modals/sources.js | 2 +- 21 files changed, 643 insertions(+), 20 deletions(-) create mode 100644 assets/js/dashboard/components/table-legacy.tsx create mode 100644 assets/js/dashboard/stats/modals/breakdown-modal-legacy.tsx create mode 100644 assets/js/dashboard/stats/modals/breakdown-table-legacy.tsx diff --git a/assets/js/dashboard/components/table-legacy.tsx b/assets/js/dashboard/components/table-legacy.tsx new file mode 100644 index 000000000000..7460fb26a2d1 --- /dev/null +++ b/assets/js/dashboard/components/table-legacy.tsx @@ -0,0 +1,216 @@ +import classNames from 'classnames' +import React, { ReactNode } from 'react' +import { SortDirection } from '../hooks/use-order-by-legacy' +import { SortButton } from './sort-button' +import { Tooltip } from '../util/tooltip' + +export type ColumnConfiguraton> = { + /** Unique column ID, used for sorting purposes and to get the value of the cell using rowItem[key] */ + key: keyof T + /** Column title */ + label: string + /** If defined, the column is considered sortable. @see SortButton */ + onSort?: () => void + sortDirection?: SortDirection + /** CSS class string. @example "w-24 md:w-32" */ + width: string + /** Aligns column content. */ + align?: 'left' | 'right' + /** A warning to be rendered as a tooltip for the column header */ + metricWarning?: string + /** + * Function used to transform the value found at item[key] for the cell. Superseded by renderItem if present. @example 1120 => "1.1k" + */ + renderValue?: (item: T, isRowHovered?: boolean) => ReactNode + /** Function used to create richer cells */ + renderItem?: (item: T) => ReactNode +} + +export const TableHeaderCell = ({ + children, + className, + align +}: { + children: ReactNode + className: string + align?: 'left' | 'right' +}) => { + return ( + + {children} + + ) +} + +export const TableCell = ({ + children, + className, + align +}: { + children: ReactNode + className: string + align?: 'left' | 'right' +}) => { + return ( + + {children} + + ) +} + +export const ItemRow = >({ + rowIndex, + pageIndex, + item, + columns, + tappedRowName, + onRowTap +}: { + rowIndex: number + pageIndex?: number + item: T + columns: ColumnConfiguraton[] + tappedRowName?: string | null + onRowTap?: (rowName: string | null) => void +}) => { + const [isHovered, setIsHovered] = React.useState(false) + + const rowName = (item as unknown as { name: string }).name + const isTapped = tappedRowName === rowName + const isRowActive = isHovered || isTapped + + const handleRowClick = (e: React.MouseEvent) => { + if (window.innerWidth < 768 && !(e.target as HTMLElement).closest('a')) { + if (onRowTap) { + if (isTapped) { + onRowTap(null) + } else { + onRowTap(rowName) + } + } + } + } + + return ( + setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + onClick={handleRowClick} + > + {columns.map(({ key, width, align, renderValue, renderItem }) => ( + + {renderItem + ? renderItem(item) + : renderValue + ? renderValue(item, isRowActive) + : (item[key] ?? '')} + + ))} + + ) +} + +export const Table = >({ + data, + columns +}: { + columns: ColumnConfiguraton[] + data: T[] | { pages: T[][] } +}) => { + const [tappedRowName, setTappedRowName] = React.useState(null) + + const renderColumnLabel = (column: ColumnConfiguraton) => { + if (column.metricWarning) { + return ( + + {column.label + ' *'} + + ) + } else { + return column.label + } + } + + const warningSpan = (warning: string) => { + return ( + + {'* ' + warning} + + ) + } + + return ( + + + + {columns.map((column) => ( + + {column.onSort ? ( + + {renderColumnLabel(column)} + + ) : ( + renderColumnLabel(column) + )} + + ))} + + + + {Array.isArray(data) + ? data.map((item, rowIndex) => ( + + )) + : data.pages.map((page, pageIndex) => + page.map((item, rowIndex) => ( + + )) + )} + +
+ ) +} diff --git a/assets/js/dashboard/hooks/use-order-by-legacy.ts b/assets/js/dashboard/hooks/use-order-by-legacy.ts index 28bde044af92..a8908f4fca9d 100644 --- a/assets/js/dashboard/hooks/use-order-by-legacy.ts +++ b/assets/js/dashboard/hooks/use-order-by-legacy.ts @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { Metric } from '../stats/reports/metrics' import { getDomainScopedStorageKey, getItem, setItem } from '../util/storage' import { useSiteContext } from '../site-context' -import { ReportInfo } from '../stats/modals/breakdown-modal' +import { ReportInfo } from '../stats/modals/breakdown-modal-legacy' export enum SortDirection { asc = 'asc', diff --git a/assets/js/dashboard/hooks/use-order-by.ts b/assets/js/dashboard/hooks/use-order-by.ts index cf4453187d8a..a3448e29615d 100644 --- a/assets/js/dashboard/hooks/use-order-by.ts +++ b/assets/js/dashboard/hooks/use-order-by.ts @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { isSortable, Metric } from '../stats/metrics' import { getDomainScopedStorageKey, getItem, setItem } from '../util/storage' import { useSiteContext } from '../site-context' -import { ReportInfo } from '../stats/modals/breakdown-modal' +import { ReportInfo } from '../stats/modals/breakdown-modal-legacy' export enum SortDirection { asc = 'asc', diff --git a/assets/js/dashboard/stats/modals/breakdown-modal-legacy.tsx b/assets/js/dashboard/stats/modals/breakdown-modal-legacy.tsx new file mode 100644 index 000000000000..12e5bb0a997b --- /dev/null +++ b/assets/js/dashboard/stats/modals/breakdown-modal-legacy.tsx @@ -0,0 +1,273 @@ +import React, { useState, ReactNode, useMemo } from 'react' + +import { useDashboardStateContext } from '../../dashboard-state-context' +import { usePaginatedGetAPI } from '../../hooks/api-client' +import { rootRoute } from '../../router' +import { + getStoredOrderBy, + Order, + OrderBy, + useOrderBy, + useRememberOrderBy +} from '../../hooks/use-order-by-legacy' +import { Metric } from '../reports/metrics' +import * as metricsModule from '../reports/metrics' +import { BreakdownResultMeta, DashboardState } from '../../dashboard-state' +import { ColumnConfiguraton } from '../../components/table-legacy' +import { BreakdownTable } from './breakdown-table-legacy' +import { useSiteContext } from '../../site-context' +import { DrilldownLink, FilterInfo } from '../../components/drilldown-link' +import { SharedReportProps } from '../reports/list' +import { hasConversionGoalFilter } from '../../util/filters' + +export type ReportInfo = { + /** Title of the report to render on the top left. */ + title: string + /** Full pathname of the API endpoint to query. @example `/api/stats/plausible.io/sources` */ + endpoint: string + /** Used as the leftmost column header. */ + dimensionLabel: string + /** What this report will be initially sorted by. @example ["visitors", "desc"] */ + defaultOrder?: Order +} + +type BreakdownModalProps = { + /** Dimension and title of the breakdown. */ + reportInfo: ReportInfo + /** Function that must return a new dashboardState that contains appropriate search filter for searchValue param. */ + addSearchFilter?: (q: DashboardState, searchValue: string) => DashboardState + searchEnabled?: boolean + /** When true, keep the percentage metric as a permanently visible, sortable column. */ + showPercentageColumn?: boolean +} + +/** + BreakdownModal is for rendering the "Details" reports on the dashboard, + i.e. a breakdown by a single (non-time) dimension, with a given set of metrics. + + BreakdownModal is expected to be rendered inside a ``, which has it's own + specific URL pathname (e.g. /plausible.io/sources). During the lifecycle of a + BreakdownModal, the `dashboardState` object is not expected to change. + + ### Search As You Type + @see BreakdownTable + + ### Filter Links + @see NameCell + + ### Pagination + @see usePaginatedGetAPI + +*/ + +export default function BreakdownModal({ + reportInfo, + metrics, + renderIcon, + getExternalLinkUrl, + searchEnabled = true, + showPercentageColumn = false, + afterFetchData, + afterFetchNextPage, + addSearchFilter, + getFilterInfo +}: Omit, 'fetchData'> & BreakdownModalProps) { + const site = useSiteContext() + const { dashboardState } = useDashboardStateContext() + const [meta, setMeta] = useState(null) + + const breakdownMetrics = useMemo(() => { + const hasPercentage = metrics.some((m) => m.key === 'percentage') + if (!hasPercentage && !hasConversionGoalFilter(dashboardState)) { + return [...metrics, metricsModule.createPercentage()] + } + return metrics + }, [metrics, dashboardState]) + + const [search, setSearch] = useState('') + const defaultOrderBy = getStoredOrderBy({ + domain: site.domain, + reportInfo, + metrics: breakdownMetrics, + fallbackValue: reportInfo.defaultOrder ? [reportInfo.defaultOrder] : [] + }) + const { orderBy, orderByDictionary, toggleSortByMetric } = useOrderBy({ + metrics: breakdownMetrics, + defaultOrderBy + }) + useRememberOrderBy({ + effectiveOrderBy: orderBy, + metrics: breakdownMetrics, + reportInfo + }) + const apiState = usePaginatedGetAPI< + { results: Array; meta: BreakdownResultMeta }, + [ + string, + { dashboardState: DashboardState; search: string; orderBy: OrderBy } + ] + >({ + siteTimezoneOffset: site.offset, + siteStatsBegin: site.statsBegin, + key: [reportInfo.endpoint, { dashboardState, search, orderBy }], + getRequestParams: (key) => { + const [_endpoint, { dashboardState, search }] = key + + let dashboardStateWithSearchFilter = { ...dashboardState } + + if ( + searchEnabled && + typeof addSearchFilter === 'function' && + search !== '' + ) { + dashboardStateWithSearchFilter = addSearchFilter(dashboardState, search) + } + + return [ + dashboardStateWithSearchFilter, + { + detailed: true, + order_by: JSON.stringify(orderBy) + } + ] + }, + afterFetchData: (response) => { + setMeta(response.meta) + afterFetchData?.(response) + }, + afterFetchNextPage + }) + + const columns: ColumnConfiguraton[] = useMemo( + () => [ + { + label: reportInfo.dimensionLabel, + key: 'name', + width: 'w-40 md:w-48', + align: 'left', + renderItem: (item) => ( + + ) + }, + ...breakdownMetrics + .filter((m) => showPercentageColumn || m.key !== 'percentage') + .map( + (m): ColumnConfiguraton => ({ + label: m.renderLabel(dashboardState), + key: m.key, + width: m.width, + align: 'right', + metricWarning: getMetricWarning(m, meta), + renderValue: (item, isRowHovered) => + m.renderValue( + showPercentageColumn && m.key === 'visitors' + ? { ...item, percentage: null } + : item, + meta, + { detailedView: true, isRowHovered } + ), + onSort: m.sortable ? () => toggleSortByMetric(m) : undefined, + sortDirection: orderByDictionary[m.key] + }) + ) + ], + [ + reportInfo.dimensionLabel, + breakdownMetrics, + getFilterInfo, + dashboardState, + orderByDictionary, + toggleSortByMetric, + renderIcon, + getExternalLinkUrl, + meta, + showPercentageColumn + ] + ) + + return ( + + title={reportInfo.title} + {...apiState} + onSearch={searchEnabled ? setSearch : undefined} + columns={columns} + /> + ) +} + +/** + * Most interactive cell in the breakdown table. + * May have an icon. + * If `getFilterInfo(item)` does not return null, + * drills down the dashboard to that particular item. + * May have a tiny icon button to navigate to the actual resource. + * */ +const NameCell = ({ + item, + getFilterInfo, + renderIcon, + getExternalLinkUrl +}: { + item: TListItem + getFilterInfo: (item: TListItem) => FilterInfo | null + renderIcon?: (item: TListItem) => ReactNode + getExternalLinkUrl?: (listItem: TListItem) => string +}) => ( +
+ {typeof renderIcon === 'function' && renderIcon(item)} + + {item.name} + + {typeof getExternalLinkUrl === 'function' && ( + + )} +
+) + +const ExternalLinkIcon = ({ url }: { url?: string }) => + url ? ( + + + + + + + ) : null + +const getMetricWarning = (metric: Metric, meta: BreakdownResultMeta | null) => { + const warnings = meta?.metric_warnings + + if (warnings && warnings[metric.key]) { + const { code, message } = warnings[metric.key] + + if (metric.key == 'bounce_rate' && code == 'no_imported_bounce_rate') { + return 'Does not include imported data' + } + if (metric.key == 'scroll_depth' && code == 'no_imported_scroll_depth') { + return 'Does not include imported data' + } + + if (metric.key == 'time_on_page' && code) { + return message + } + } +} diff --git a/assets/js/dashboard/stats/modals/breakdown-modal.tsx b/assets/js/dashboard/stats/modals/breakdown-modal.tsx index 7852b15cf558..12e5bb0a997b 100644 --- a/assets/js/dashboard/stats/modals/breakdown-modal.tsx +++ b/assets/js/dashboard/stats/modals/breakdown-modal.tsx @@ -13,8 +13,8 @@ import { import { Metric } from '../reports/metrics' import * as metricsModule from '../reports/metrics' import { BreakdownResultMeta, DashboardState } from '../../dashboard-state' -import { ColumnConfiguraton } from '../../components/table' -import { BreakdownTable } from './breakdown-table' +import { ColumnConfiguraton } from '../../components/table-legacy' +import { BreakdownTable } from './breakdown-table-legacy' import { useSiteContext } from '../../site-context' import { DrilldownLink, FilterInfo } from '../../components/drilldown-link' import { SharedReportProps } from '../reports/list' diff --git a/assets/js/dashboard/stats/modals/breakdown-table-legacy.tsx b/assets/js/dashboard/stats/modals/breakdown-table-legacy.tsx new file mode 100644 index 000000000000..0944f3afb203 --- /dev/null +++ b/assets/js/dashboard/stats/modals/breakdown-table-legacy.tsx @@ -0,0 +1,134 @@ +import React, { ReactNode, useRef } from 'react' +import { XMarkIcon } from '@heroicons/react/20/solid' + +import { SearchInput } from '../../components/search-input' +import { ColumnConfiguraton, Table } from '../../components/table-legacy' +import RocketIcon from './rocket-icon' +import { QueryStatus } from '@tanstack/react-query' +import { useAppNavigate } from '../../navigation/use-app-navigate' +import { rootRoute } from '../../router' + +export const BreakdownTable = ({ + title, + isPending, + isFetching, + onSearch, + hasNextPage, + isFetchingNextPage, + fetchNextPage, + columns, + data, + status, + error, + displayError, + onClose +}: { + title: ReactNode + onSearch?: (input: string) => void + isPending: boolean + isFetching: boolean + hasNextPage: boolean + isFetchingNextPage: boolean + fetchNextPage: () => void + columns: ColumnConfiguraton[] + data?: { pages: TListItem[][] } + status?: QueryStatus + error?: Error | null + /** Controls whether the component displays API request errors or ignores them. */ + displayError?: boolean + onClose?: () => void +}) => { + const searchRef = useRef(null) + const navigate = useAppNavigate() + const handleClose = + onClose ?? (() => navigate({ path: rootRoute.path, search: (s) => s })) + + return ( + <> +
+
+

+ {title} +

+ {!!onSearch && ( + + )} + {!isPending && isFetching && } +
+ +
+
+
+ {displayError && status === 'error' && } + {isPending && } + {data && data={data} columns={columns} />} + {!isPending && !isFetching && hasNextPage && ( + fetchNextPage()} + isFetchingNextPage={isFetchingNextPage} + /> + )} +
+ + ) +} + +const InitialLoadingSpinner = () => ( +
+
+
+
+
+) + +const SmallLoadingSpinner = () => ( +
+
+
+) + +const ErrorMessage = ({ error }: { error?: unknown }) => ( +
+
+ +
+
+ {error + ? (error as { message: string }).message + : 'Error loading data. Refresh the page to try again'} +
+
+) + +const LoadMore = ({ + onClick, + isFetchingNextPage +}: { + onClick: () => void + isFetchingNextPage: boolean +}) => ( +
+ {isFetchingNextPage ? ( + + ) : ( + + )} +
+) diff --git a/assets/js/dashboard/stats/modals/breakdown-table.tsx b/assets/js/dashboard/stats/modals/breakdown-table.tsx index 878bd0b782ae..18ca9cde25a4 100644 --- a/assets/js/dashboard/stats/modals/breakdown-table.tsx +++ b/assets/js/dashboard/stats/modals/breakdown-table.tsx @@ -2,7 +2,7 @@ import React, { ReactNode, useRef } from 'react' import { XMarkIcon } from '@heroicons/react/20/solid' import { SearchInput } from '../../components/search-input' -import { ColumnConfiguraton, Table } from '../../components/table' +import { ColumnConfiguraton, Table } from '../../components/table-legacy' import RocketIcon from './rocket-icon' import { QueryStatus } from '@tanstack/react-query' import { useAppNavigate } from '../../navigation/use-app-navigate' diff --git a/assets/js/dashboard/stats/modals/conversions.js b/assets/js/dashboard/stats/modals/conversions.js index 92d6834f1b18..b5e3e523eca1 100644 --- a/assets/js/dashboard/stats/modals/conversions.js +++ b/assets/js/dashboard/stats/modals/conversions.js @@ -1,7 +1,7 @@ import React, { useCallback, useState } from 'react' import Modal from './modal' -import BreakdownModal from './breakdown-modal' +import BreakdownModal from './breakdown-modal-legacy' import * as metrics from '../reports/metrics' import * as url from '../../util/url' import { useSiteContext } from '../../site-context' diff --git a/assets/js/dashboard/stats/modals/devices/browser-versions-modal.js b/assets/js/dashboard/stats/modals/devices/browser-versions-modal.js index 202f501f31b2..2197ca03be0d 100644 --- a/assets/js/dashboard/stats/modals/devices/browser-versions-modal.js +++ b/assets/js/dashboard/stats/modals/devices/browser-versions-modal.js @@ -1,7 +1,7 @@ import React, { useCallback } from 'react' import Modal from './../modal' import { addFilter } from '../../../dashboard-state' -import BreakdownModal from './../breakdown-modal' +import BreakdownModal from '../breakdown-modal-legacy' import * as url from '../../../util/url' import { useDashboardStateContext } from '../../../dashboard-state-context' import { useSiteContext } from '../../../site-context' diff --git a/assets/js/dashboard/stats/modals/devices/browsers-modal.js b/assets/js/dashboard/stats/modals/devices/browsers-modal.js index bb836a8c76aa..f6a14ce1bbf7 100644 --- a/assets/js/dashboard/stats/modals/devices/browsers-modal.js +++ b/assets/js/dashboard/stats/modals/devices/browsers-modal.js @@ -1,7 +1,7 @@ import React, { useCallback } from 'react' import Modal from './../modal' import { addFilter } from '../../../dashboard-state' -import BreakdownModal from './../breakdown-modal' +import BreakdownModal from '../breakdown-modal-legacy' import * as url from '../../../util/url' import { useDashboardStateContext } from '../../../dashboard-state-context' import { useSiteContext } from '../../../site-context' diff --git a/assets/js/dashboard/stats/modals/devices/operating-system-versions-modal.js b/assets/js/dashboard/stats/modals/devices/operating-system-versions-modal.js index 1e43bcf850e3..50b20d45679c 100644 --- a/assets/js/dashboard/stats/modals/devices/operating-system-versions-modal.js +++ b/assets/js/dashboard/stats/modals/devices/operating-system-versions-modal.js @@ -1,7 +1,7 @@ import React, { useCallback } from 'react' import Modal from './../modal' import { addFilter } from '../../../dashboard-state' -import BreakdownModal from './../breakdown-modal' +import BreakdownModal from '../breakdown-modal-legacy' import * as url from '../../../util/url' import { useDashboardStateContext } from '../../../dashboard-state-context' import { useSiteContext } from '../../../site-context' diff --git a/assets/js/dashboard/stats/modals/devices/operating-systems-modal.js b/assets/js/dashboard/stats/modals/devices/operating-systems-modal.js index 31ba8e06b179..8685c2158abe 100644 --- a/assets/js/dashboard/stats/modals/devices/operating-systems-modal.js +++ b/assets/js/dashboard/stats/modals/devices/operating-systems-modal.js @@ -1,7 +1,7 @@ import React, { useCallback } from 'react' import Modal from './../modal' import { addFilter } from '../../../dashboard-state' -import BreakdownModal from './../breakdown-modal' +import BreakdownModal from '../breakdown-modal-legacy' import * as url from '../../../util/url' import { useDashboardStateContext } from '../../../dashboard-state-context' import { useSiteContext } from '../../../site-context' diff --git a/assets/js/dashboard/stats/modals/devices/screen-sizes.js b/assets/js/dashboard/stats/modals/devices/screen-sizes.js index 9d75d1c277b6..15828f1fc013 100644 --- a/assets/js/dashboard/stats/modals/devices/screen-sizes.js +++ b/assets/js/dashboard/stats/modals/devices/screen-sizes.js @@ -1,6 +1,6 @@ import React, { useCallback } from 'react' import Modal from './../modal' -import BreakdownModal from './../breakdown-modal' +import BreakdownModal from '../breakdown-modal-legacy' import * as url from '../../../util/url' import { useDashboardStateContext } from '../../../dashboard-state-context' import { useSiteContext } from '../../../site-context' diff --git a/assets/js/dashboard/stats/modals/entry-pages.js b/assets/js/dashboard/stats/modals/entry-pages.js index 43eff1a232c3..4affbffde5af 100644 --- a/assets/js/dashboard/stats/modals/entry-pages.js +++ b/assets/js/dashboard/stats/modals/entry-pages.js @@ -5,7 +5,7 @@ import { isRealTimeDashboard } from '../../util/filters' import { addFilter, revenueAvailable } from '../../dashboard-state' -import BreakdownModal from './breakdown-modal' +import BreakdownModal from './breakdown-modal-legacy' import * as metrics from '../reports/metrics' import * as url from '../../util/url' import { useDashboardStateContext } from '../../dashboard-state-context' diff --git a/assets/js/dashboard/stats/modals/exit-pages.js b/assets/js/dashboard/stats/modals/exit-pages.js index 75b4cd00c2df..ed80b47f93c1 100644 --- a/assets/js/dashboard/stats/modals/exit-pages.js +++ b/assets/js/dashboard/stats/modals/exit-pages.js @@ -5,7 +5,7 @@ import { isRealTimeDashboard } from '../../util/filters' import { addFilter, revenueAvailable } from '../../dashboard-state' -import BreakdownModal from './breakdown-modal' +import BreakdownModal from './breakdown-modal-legacy' import * as metrics from '../reports/metrics' import * as url from '../../util/url' import { useDashboardStateContext } from '../../dashboard-state-context' diff --git a/assets/js/dashboard/stats/modals/google-keywords.tsx b/assets/js/dashboard/stats/modals/google-keywords.tsx index cf8ce484de4a..256d172eb393 100644 --- a/assets/js/dashboard/stats/modals/google-keywords.tsx +++ b/assets/js/dashboard/stats/modals/google-keywords.tsx @@ -11,8 +11,8 @@ import { } from '../../util/number-formatter' import { apiPath } from '../../util/url' import { DashboardState } from '../../dashboard-state' -import { ColumnConfiguraton } from '../../components/table' -import { BreakdownTable } from './breakdown-table' +import { ColumnConfiguraton } from '../../components/table-legacy' +import { BreakdownTable } from './breakdown-table-legacy' type GoogleKeywordItem = { visitors: string diff --git a/assets/js/dashboard/stats/modals/locations-modal.js b/assets/js/dashboard/stats/modals/locations-modal.js index 56a984c7fa5f..fbfdd97c9203 100644 --- a/assets/js/dashboard/stats/modals/locations-modal.js +++ b/assets/js/dashboard/stats/modals/locations-modal.js @@ -5,7 +5,7 @@ import { hasConversionGoalFilter, isRealTimeDashboard } from '../../util/filters' -import BreakdownModal from './breakdown-modal' +import BreakdownModal from './breakdown-modal-legacy' import * as metrics from '../reports/metrics' import * as url from '../../util/url' import { useDashboardStateContext } from '../../dashboard-state-context' diff --git a/assets/js/dashboard/stats/modals/pages.js b/assets/js/dashboard/stats/modals/pages.js index 0cadd154336a..f374c6dfe4c3 100644 --- a/assets/js/dashboard/stats/modals/pages.js +++ b/assets/js/dashboard/stats/modals/pages.js @@ -5,7 +5,7 @@ import { isRealTimeDashboard } from '../../util/filters' import { addFilter, revenueAvailable } from '../../dashboard-state' -import BreakdownModal from './breakdown-modal' +import BreakdownModal from './breakdown-modal-legacy' import * as metrics from '../reports/metrics' import * as url from '../../util/url' import { useDashboardStateContext } from '../../dashboard-state-context' diff --git a/assets/js/dashboard/stats/modals/props.js b/assets/js/dashboard/stats/modals/props.js index bc1361928d4d..eae9c6344c2d 100644 --- a/assets/js/dashboard/stats/modals/props.js +++ b/assets/js/dashboard/stats/modals/props.js @@ -9,7 +9,7 @@ import { getGoalFilter, hasConversionGoalFilter } from '../../util/filters' -import BreakdownModal from './breakdown-modal' +import BreakdownModal from './breakdown-modal-legacy' import * as metrics from '../reports/metrics' import * as url from '../../util/url' import { useDashboardStateContext } from '../../dashboard-state-context' diff --git a/assets/js/dashboard/stats/modals/referrer-drilldown.js b/assets/js/dashboard/stats/modals/referrer-drilldown.js index 44239b677e77..6ee599a4ac29 100644 --- a/assets/js/dashboard/stats/modals/referrer-drilldown.js +++ b/assets/js/dashboard/stats/modals/referrer-drilldown.js @@ -6,7 +6,7 @@ import { hasConversionGoalFilter, isRealTimeDashboard } from '../../util/filters' -import BreakdownModal from './breakdown-modal' +import BreakdownModal from './breakdown-modal-legacy' import * as metrics from '../reports/metrics' import * as url from '../../util/url' import { addFilter, revenueAvailable } from '../../dashboard-state' diff --git a/assets/js/dashboard/stats/modals/sources.js b/assets/js/dashboard/stats/modals/sources.js index fa3ec2b274da..7d06fb8cde3e 100644 --- a/assets/js/dashboard/stats/modals/sources.js +++ b/assets/js/dashboard/stats/modals/sources.js @@ -4,7 +4,7 @@ import { hasConversionGoalFilter, isRealTimeDashboard } from '../../util/filters' -import BreakdownModal from './breakdown-modal' +import BreakdownModal from './breakdown-modal-legacy' import * as metrics from '../reports/metrics' import * as url from '../../util/url' import { addFilter, revenueAvailable } from '../../dashboard-state' From 6078c206c7a78b277273eda65779065b97c2d765 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Sun, 19 Apr 2026 18:40:07 +0100 Subject: [PATCH 09/40] add v2 (details) breakdown components --- assets/js/dashboard/components/table.tsx | 234 ++----- assets/js/dashboard/stats/breakdowns.tsx | 150 ++++ .../stats/modals/breakdown-modal.tsx | 649 ++++++++++++------ .../stats/modals/breakdown-table.tsx | 23 +- 4 files changed, 661 insertions(+), 395 deletions(-) create mode 100644 assets/js/dashboard/stats/breakdowns.tsx diff --git a/assets/js/dashboard/components/table.tsx b/assets/js/dashboard/components/table.tsx index 7460fb26a2d1..2fde7a256570 100644 --- a/assets/js/dashboard/components/table.tsx +++ b/assets/js/dashboard/components/table.tsx @@ -1,106 +1,27 @@ import classNames from 'classnames' -import React, { ReactNode } from 'react' -import { SortDirection } from '../hooks/use-order-by-legacy' -import { SortButton } from './sort-button' -import { Tooltip } from '../util/tooltip' +import React, { useState } from 'react' +import { ColumnConfiguration } from '../stats/breakdowns' -export type ColumnConfiguraton> = { - /** Unique column ID, used for sorting purposes and to get the value of the cell using rowItem[key] */ - key: keyof T - /** Column title */ - label: string - /** If defined, the column is considered sortable. @see SortButton */ - onSort?: () => void - sortDirection?: SortDirection - /** CSS class string. @example "w-24 md:w-32" */ - width: string - /** Aligns column content. */ - align?: 'left' | 'right' - /** A warning to be rendered as a tooltip for the column header */ - metricWarning?: string - /** - * Function used to transform the value found at item[key] for the cell. Superseded by renderItem if present. @example 1120 => "1.1k" - */ - renderValue?: (item: T, isRowHovered?: boolean) => ReactNode - /** Function used to create richer cells */ - renderItem?: (item: T) => ReactNode -} - -export const TableHeaderCell = ({ - children, - className, - align -}: { - children: ReactNode - className: string - align?: 'left' | 'right' -}) => { - return ( - - {children} - - ) -} - -export const TableCell = ({ - children, - className, - align -}: { - children: ReactNode - className: string - align?: 'left' | 'right' -}) => { - return ( - - {children} - - ) -} - -export const ItemRow = >({ - rowIndex, - pageIndex, - item, +function Row({ + row, columns, - tappedRowName, - onRowTap + rowKey, + tappedKey, + onTap }: { - rowIndex: number - pageIndex?: number - item: T - columns: ColumnConfiguraton[] - tappedRowName?: string | null - onRowTap?: (rowName: string | null) => void -}) => { - const [isHovered, setIsHovered] = React.useState(false) + row: T + columns: ColumnConfiguration[] + rowKey: string + tappedKey: string | null + onTap: (key: string | null) => void +}) { + const [isHovered, setIsHovered] = useState(false) + const isTapped = tappedKey === rowKey + const isActive = isHovered || isTapped - const rowName = (item as unknown as { name: string }).name - const isTapped = tappedRowName === rowName - const isRowActive = isHovered || isTapped - - const handleRowClick = (e: React.MouseEvent) => { + const handleClick = (e: React.MouseEvent) => { if (window.innerWidth < 768 && !(e.target as HTMLElement).closest('a')) { - if (onRowTap) { - if (isTapped) { - onRowTap(null) - } else { - onRowTap(rowName) - } - } + onTap(isTapped ? null : rowKey) } } @@ -110,106 +31,67 @@ export const ItemRow = >({ className="group text-sm dark:text-gray-200 md:cursor-default cursor-pointer" onMouseEnter={() => setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} - onClick={handleRowClick} + onClick={handleClick} > - {columns.map(({ key, width, align, renderValue, renderItem }) => ( - ( + - {renderItem - ? renderItem(item) - : renderValue - ? renderValue(item, isRowActive) - : (item[key] ?? '')} - + {col.renderCell(row, isActive)} + ))} ) } -export const Table = >({ +export function Table({ data, - columns + columns, + getRowKey }: { - columns: ColumnConfiguraton[] - data: T[] | { pages: T[][] } -}) => { - const [tappedRowName, setTappedRowName] = React.useState(null) - - const renderColumnLabel = (column: ColumnConfiguraton) => { - if (column.metricWarning) { - return ( - - {column.label + ' *'} - - ) - } else { - return column.label - } - } - - const warningSpan = (warning: string) => { - return ( - - {'* ' + warning} - - ) - } + data: { pages: T[][] } + columns: ColumnConfiguration[] + getRowKey: (row: T) => string +}) { + const [tappedKey, setTappedKey] = useState(null) return ( - {columns.map((column) => ( - ( + ))} - {Array.isArray(data) - ? data.map((item, rowIndex) => ( - + page.map((row) => { + const rowKey = getRowKey(row) + return ( + - )) - : data.pages.map((page, pageIndex) => - page.map((item, rowIndex) => ( - - )) - )} + ) + }) + )}
- {column.onSort ? ( - - {renderColumnLabel(column)} - - ) : ( - renderColumnLabel(column) - )} - + {col.renderLabel()} +
) diff --git a/assets/js/dashboard/stats/breakdowns.tsx b/assets/js/dashboard/stats/breakdowns.tsx new file mode 100644 index 000000000000..2541439b1fcd --- /dev/null +++ b/assets/js/dashboard/stats/breakdowns.tsx @@ -0,0 +1,150 @@ +import React, { ReactNode, useEffect, useRef } from 'react' +import { SortDirection } from '../hooks/use-order-by' +import type { QueryResultRow, QueryResultQuery, QueryApiResponse } from '../api' +import { Metric } from './metrics' +import { FilterInfo } from '../components/drilldown-link' +import { ChangeArrow } from './reports/change-arrow' +import { MetricFormatterLong, ValueType } from './reports/metric-formatter' +import dayjs from 'dayjs' +import { addFilter, ApiFilter, StatsQuery } from '../stats-query' + +export type SharedBreakdownReportProps = { + dimensionLabel: string + dimensions: string[] + metrics: Metric[] + getFilterInfo: (row: QueryResultRow) => FilterInfo | null + getExternalLinkUrl?: (row: QueryResultRow) => string | null + afterFetchData?: (response: QueryApiResponse) => void +} + +export type ColumnConfiguration = { + /** Unique column ID, used for sorting purposes and as a React key */ + key: string + /** Column title */ + renderLabel: () => ReactNode + /** Renders any cell in this column — name cells, metric cells, etc. */ + renderCell: (item: T, isActive?: boolean) => ReactNode + /** If defined, the column is considered sortable. @see SortButton */ + onSort?: () => void + sortDirection?: SortDirection + /** CSS class string. @example "w-24 md:w-32" */ + width?: string + /** Aligns column content. */ + align?: 'left' | 'right' +} + +export function MetricValueTooltipContent({ + value, + comparison, + metric, + metricLabel, + dateRangeLabel, + comparisonDateRangeLabel +}: { + value: ValueType + comparison: { value: ValueType; change: number } | null + metric: Metric + metricLabel: string + dateRangeLabel: string + comparisonDateRangeLabel: string | null +}) { + const longFormatter = MetricFormatterLong[metric] + const label = metricLabel.length >= 3 ? ` ${metricLabel.toLowerCase()}` : '' + + if (comparison && comparisonDateRangeLabel) { + return ( +
+
+
+
+ + {longFormatter(value)} + {label} + +
+ {dateRangeLabel} +
+
+ +
+
+
+
+
+ {longFormatter(comparison.value)} + {label} +
+
+ {comparisonDateRangeLabel} +
+
+
+ ) + } + + return
{longFormatter(value)}
+} + +export function formatDateRangeLabel([from, to]: [string, string]): string { + const fromDay = dayjs(from.slice(0, 19)) + const toDay = dayjs(to.slice(0, 19)) + if (fromDay.isSame(toDay, 'day')) return fromDay.format('D MMM YYYY') + if (fromDay.isSame(toDay, 'year')) + return `${fromDay.format('D MMM')} – ${toDay.format('D MMM YYYY')}` + return `${fromDay.format('D MMM YY')} – ${toDay.format('D MMM YY')}` +} + +export function useBodyPortalRef() { + const portalRef = useRef(null) + useEffect(() => { + if (typeof document !== 'undefined') { + portalRef.current = document.body + } + }, []) + return portalRef +} + +export function ExternalLinkIcon() { + return ( + + + + ) +} + +export function extractMetricValue( + row: QueryResultRow, + query: QueryResultQuery, + metricKey: string +): { + metricIndex: number + value: ValueType + comparison: { value: ValueType; change: number } | null +} { + const metricIndex = query.metrics.indexOf(metricKey as Metric) + const value: ValueType = + metricIndex >= 0 ? (row.metrics[metricIndex] ?? null) : null + const comparison = + row.comparison && query.comparison_date_range + ? { + value: row.comparison.metrics[metricIndex] ?? null, + change: row.comparison.change[metricIndex] + } + : null + return { metricIndex, value, comparison } +} diff --git a/assets/js/dashboard/stats/modals/breakdown-modal.tsx b/assets/js/dashboard/stats/modals/breakdown-modal.tsx index 12e5bb0a997b..0b59ddef814d 100644 --- a/assets/js/dashboard/stats/modals/breakdown-modal.tsx +++ b/assets/js/dashboard/stats/modals/breakdown-modal.tsx @@ -1,273 +1,502 @@ -import React, { useState, ReactNode, useMemo } from 'react' - +import React, { ReactNode, useCallback, useMemo, useState } from 'react' import { useDashboardStateContext } from '../../dashboard-state-context' -import { usePaginatedGetAPI } from '../../hooks/api-client' +import { usePaginatedQueryAPI } from '../../hooks/api-client' import { rootRoute } from '../../router' import { getStoredOrderBy, Order, OrderBy, + SortDirection, useOrderBy, useRememberOrderBy -} from '../../hooks/use-order-by-legacy' -import { Metric } from '../reports/metrics' -import * as metricsModule from '../reports/metrics' -import { BreakdownResultMeta, DashboardState } from '../../dashboard-state' -import { ColumnConfiguraton } from '../../components/table-legacy' -import { BreakdownTable } from './breakdown-table-legacy' +} from '../../hooks/use-order-by' +import { Metric, getBreakdownMetricLabel, isSortable } from '../metrics' +import { BreakdownTable } from './breakdown-table' +import { createStatsQuery, StatsQuery } from '../../stats-query' import { useSiteContext } from '../../site-context' import { DrilldownLink, FilterInfo } from '../../components/drilldown-link' -import { SharedReportProps } from '../reports/list' -import { hasConversionGoalFilter } from '../../util/filters' - -export type ReportInfo = { - /** Title of the report to render on the top left. */ - title: string - /** Full pathname of the API endpoint to query. @example `/api/stats/plausible.io/sources` */ - endpoint: string - /** Used as the leftmost column header. */ - dimensionLabel: string - /** What this report will be initially sorted by. @example ["visitors", "desc"] */ - defaultOrder?: Order -} +import { + ColumnConfiguration, + ExternalLinkIcon, + MetricValueTooltipContent, + SharedBreakdownReportProps, + formatDateRangeLabel, + useBodyPortalRef, + extractMetricValue +} from '../breakdowns' +import { + QueryResultRow, + QueryResultMeta, + QueryResultQuery, + QueryApiResponse +} from '../../api' +import classNames from 'classnames' +import { Tooltip } from '../../util/tooltip' +import { ChangeArrow } from '../reports/change-arrow' +import { + MetricFormatterShort, + MetricFormatterLong +} from '../reports/metric-formatter' +import { + hasConversionGoalFilter, + isRealTimeDashboard +} from '../../util/filters' +import { SortButton } from '../../components/sort-button' -type BreakdownModalProps = { - /** Dimension and title of the breakdown. */ - reportInfo: ReportInfo - /** Function that must return a new dashboardState that contains appropriate search filter for searchValue param. */ - addSearchFilter?: (q: DashboardState, searchValue: string) => DashboardState - searchEnabled?: boolean - /** When true, keep the percentage metric as a permanently visible, sortable column. */ - showPercentageColumn?: boolean +type DetailsBreakdownProps = SharedBreakdownReportProps & { + title: ReactNode + defaultOrderBy?: OrderBy + addSearchFilter?: (statsQuery: StatsQuery, search: string) => StatsQuery + afterFetchNextPage?: (response: QueryApiResponse) => void } -/** - BreakdownModal is for rendering the "Details" reports on the dashboard, - i.e. a breakdown by a single (non-time) dimension, with a given set of metrics. - - BreakdownModal is expected to be rendered inside a ``, which has it's own - specific URL pathname (e.g. /plausible.io/sources). During the lifecycle of a - BreakdownModal, the `dashboardState` object is not expected to change. - - ### Search As You Type - @see BreakdownTable +const getMetricCellWidthClass = ( + metric: Metric, + metricLabel: string +): string => { + if (['average_revenue', 'total_revenue'].includes(metric)) { + return 'w-32 min-w-32' + } - ### Filter Links - @see NameCell + if (metricLabel.length < 3) { + return 'w-28 min-w-28 md:w-24 md:min-w-24' + } - ### Pagination - @see usePaginatedGetAPI + if (metricLabel.length < 15) { + return 'w-28 min-w-28' + } -*/ + return 'w-32 min-w-32' +} -export default function BreakdownModal({ - reportInfo, +export function DetailsBreakdown({ + title, + dimensionLabel, + dimensions, metrics, - renderIcon, + defaultOrderBy = [] as OrderBy, + getFilterInfo, getExternalLinkUrl, - searchEnabled = true, - showPercentageColumn = false, - afterFetchData, - afterFetchNextPage, addSearchFilter, - getFilterInfo -}: Omit, 'fetchData'> & BreakdownModalProps) { + afterFetchData, + afterFetchNextPage +}: DetailsBreakdownProps) { const site = useSiteContext() const { dashboardState } = useDashboardStateContext() - const [meta, setMeta] = useState(null) - - const breakdownMetrics = useMemo(() => { - const hasPercentage = metrics.some((m) => m.key === 'percentage') - if (!hasPercentage && !hasConversionGoalFilter(dashboardState)) { - return [...metrics, metricsModule.createPercentage()] - } - return metrics - }, [metrics, dashboardState]) - const [search, setSearch] = useState('') - const defaultOrderBy = getStoredOrderBy({ + const [meta, setMeta] = useState(null) + const [query, setQuery] = useState(null) + + const storedOrderBy = getStoredOrderBy({ domain: site.domain, - reportInfo, - metrics: breakdownMetrics, - fallbackValue: reportInfo.defaultOrder ? [reportInfo.defaultOrder] : [] + reportInfo: { dimensionLabel }, + metrics, + fallbackValue: defaultOrderBy }) + const { orderBy, orderByDictionary, toggleSortByMetric } = useOrderBy({ - metrics: breakdownMetrics, - defaultOrderBy + metrics, + defaultOrderBy: storedOrderBy }) + useRememberOrderBy({ effectiveOrderBy: orderBy, - metrics: breakdownMetrics, - reportInfo + metrics, + reportInfo: { dimensionLabel } }) - const apiState = usePaginatedGetAPI< - { results: Array; meta: BreakdownResultMeta }, - [ - string, - { dashboardState: DashboardState; search: string; orderBy: OrderBy } - ] - >({ - siteTimezoneOffset: site.offset, - siteStatsBegin: site.statsBegin, - key: [reportInfo.endpoint, { dashboardState, search, orderBy }], - getRequestParams: (key) => { - const [_endpoint, { dashboardState, search }] = key - - let dashboardStateWithSearchFilter = { ...dashboardState } - - if ( - searchEnabled && - typeof addSearchFilter === 'function' && - search !== '' - ) { - dashboardStateWithSearchFilter = addSearchFilter(dashboardState, search) - } - return [ - dashboardStateWithSearchFilter, - { - detailed: true, - order_by: JSON.stringify(orderBy) - } - ] - }, - afterFetchData: (response) => { + const effectiveOrderBy = orderBy.length ? orderBy : storedOrderBy + + const baseStatsQuery: StatsQuery = useMemo( + () => + createStatsQuery(dashboardState, { + metrics: metrics, + dimensions, + order_by: effectiveOrderBy as Order[] + }), + [dashboardState, metrics, dimensions, effectiveOrderBy] + ) + + const statsQuery: StatsQuery = useMemo(() => { + if (search && addSearchFilter) { + return addSearchFilter(baseStatsQuery, search) + } + return baseStatsQuery + }, [baseStatsQuery, search, addSearchFilter]) + + const handleAfterFetchData = useCallback( + (response: QueryApiResponse) => { setMeta(response.meta) + setQuery(response.query) afterFetchData?.(response) }, + [afterFetchData] + ) + + const apiState = usePaginatedQueryAPI({ + site, + statsQuery, + afterFetchData: handleAfterFetchData, afterFetchNextPage }) - const columns: ColumnConfiguraton[] = useMemo( - () => [ + const metricLabelFor = useCallback( + (metric: Metric): string => { + return getBreakdownMetricLabel(metric, { + hasConversionGoalFilter: hasConversionGoalFilter(dashboardState), + isRealtime: isRealTimeDashboard(dashboardState), + dimensions: dimensions + }) + }, + [dashboardState, dimensions] + ) + + const columns: ColumnConfiguration[] | null = useMemo(() => { + if (!query) return null + + const hasPercentage = query.metrics.includes('percentage') + const isVisitorsWithPercentageCell = (m: Metric) => + hasPercentage && m === 'visitors' + + return [ { - label: reportInfo.dimensionLabel, - key: 'name', - width: 'w-40 md:w-48', - align: 'left', - renderItem: (item) => ( - dimensionLabel, + renderCell: (row, isActive) => ( + - ) + ), + align: 'left' }, - ...breakdownMetrics - .filter((m) => showPercentageColumn || m.key !== 'percentage') + ...query.metrics + // Percentage is not its own column — shown inline in the visitors cell + .filter((metric) => metric !== 'percentage') .map( - (m): ColumnConfiguraton => ({ - label: m.renderLabel(dashboardState), - key: m.key, - width: m.width, - align: 'right', - metricWarning: getMetricWarning(m, meta), - renderValue: (item, isRowHovered) => - m.renderValue( - showPercentageColumn && m.key === 'visitors' - ? { ...item, percentage: null } - : item, - meta, - { detailedView: true, isRowHovered } - ), - onSort: m.sortable ? () => toggleSortByMetric(m) : undefined, - sortDirection: orderByDictionary[m.key] + (metric): ColumnConfiguration => ({ + key: metric, + renderLabel: () => ( + toggleSortByMetric(metric)} + sortDirection={orderByDictionary[metric] ?? null} + /> + ), + renderCell: (row, isActive) => { + if (isVisitorsWithPercentageCell(metric)) { + return ( + + ) + } else { + return ( + + ) + } + }, + onSort: isSortable(metric) + ? () => toggleSortByMetric(metric) + : undefined, + sortDirection: orderByDictionary[metric], + width: isVisitorsWithPercentageCell(metric) + ? 'w-36' + : getMetricCellWidthClass(metric, metricLabelFor(metric)), + align: 'right' }) ) - ], - [ - reportInfo.dimensionLabel, - breakdownMetrics, - getFilterInfo, - dashboardState, - orderByDictionary, - toggleSortByMetric, - renderIcon, - getExternalLinkUrl, - meta, - showPercentageColumn ] - ) + }, [ + dimensionLabel, + query, + meta, + getFilterInfo, + getExternalLinkUrl, + orderByDictionary, + toggleSortByMetric, + metricLabelFor + ]) return ( - - title={reportInfo.title} + + title={title} {...apiState} - onSearch={searchEnabled ? setSearch : undefined} columns={columns} + onSearch={addSearchFilter ? setSearch : undefined} + getRowKey={(row) => row.dimensions[0]} /> ) } -/** - * Most interactive cell in the breakdown table. - * May have an icon. - * If `getFilterInfo(item)` does not return null, - * drills down the dashboard to that particular item. - * May have a tiny icon button to navigate to the actual resource. - * */ -const NameCell = ({ - item, - getFilterInfo, - renderIcon, - getExternalLinkUrl +function VisitorsWithPercentageCell({ + row, + query, + isActive }: { - item: TListItem - getFilterInfo: (item: TListItem) => FilterInfo | null - renderIcon?: (item: TListItem) => ReactNode - getExternalLinkUrl?: (listItem: TListItem) => string -}) => ( -
- {typeof renderIcon === 'function' && renderIcon(item)} - - {item.name} - - {typeof getExternalLinkUrl === 'function' && ( - - )} -
-) - -const ExternalLinkIcon = ({ url }: { url?: string }) => - url ? ( - + ) + + const visitorsCell = ( + + {isActive + ? visitorsLongFormatter(visitorsValue) + : visitorsShortFormatter(visitorsValue)} + {visitorsComparison && ( + + )} + + ) + + const visitorsWithTooltip = showTooltip ? ( + } + info={ + + } > - - - - - - ) : null + {visitorsCell} + + ) : ( + visitorsCell + ) -const getMetricWarning = (metric: Metric, meta: BreakdownResultMeta | null) => { - const warnings = meta?.metric_warnings + return ( +
+ {percentageCell} + {visitorsWithTooltip} +
+ ) +} - if (warnings && warnings[metric.key]) { - const { code, message } = warnings[metric.key] +function MetricValueCell({ + row, + metric, + metricLabel, + query, + isActive +}: { + row: QueryResultRow + metric: Metric + metricLabel: string + query: QueryResultQuery + isActive?: boolean +}) { + const portalRef = useBodyPortalRef() - if (metric.key == 'bounce_rate' && code == 'no_imported_bounce_rate') { - return 'Does not include imported data' - } - if (metric.key == 'scroll_depth' && code == 'no_imported_scroll_depth') { - return 'Does not include imported data' - } + const { value, comparison } = extractMetricValue(row, query, metric) - if (metric.key == 'time_on_page' && code) { - return message - } + const shortFormatter = MetricFormatterShort[metric] + const longFormatter = MetricFormatterLong[metric] + + // Show long format when the row is active (hovered on desktop, tapped on mobile) + const displayFormatter = isActive ? longFormatter : shortFormatter + + // Tooltip is used for comparison mode only + const showTooltip = !!comparison + + const valueContent = ( + + {displayFormatter(value)} + {comparison && ( + + )} + + ) + + if (!showTooltip) return valueContent + + const dateRangeLabel = formatDateRangeLabel(query.date_range) + const comparisonDateRangeLabel = query.comparison_date_range + ? formatDateRangeLabel(query.comparison_date_range) + : null + + return ( + } + info={ + + } + > + {valueContent} + + ) +} + +function getMetricWarning( + metricKey: string, + meta: QueryResultMeta | null +): string | null { + const warnings = meta?.metric_warnings + if (!warnings || !warnings[metricKey]) return null + const { code, message } = warnings[metricKey] + if (metricKey === 'bounce_rate' && code === 'no_imported_bounce_rate') { + return 'Does not include imported data' + } + if (metricKey === 'scroll_depth' && code === 'no_imported_scroll_depth') { + return 'Does not include imported data' + } + if (metricKey === 'time_on_page' && code) { + return message + } + return null +} + +function MetricLabel({ + label, + warning, + sortable, + toggleSort, + sortDirection +}: { + label: string + warning: string | null + sortable: boolean + toggleSort: () => void + sortDirection: SortDirection | null +}) { + const labelText = label + (warning ? ' *' : '') + const inner = sortable ? ( + + {labelText} + + ) : ( + labelText + ) + if (warning) { + return ( + + {'* ' + warning} + + } + className="inline-block" + > + {inner} + + ) + } else { + return <>{inner} } } + +function DimensionCell({ + row, + getFilterInfo, + getExternalLinkUrl, + isActive +}: { + row: QueryResultRow + getFilterInfo: (row: QueryResultRow) => FilterInfo | null + getExternalLinkUrl?: (row: QueryResultRow) => string | null + isActive?: boolean +}) { + return ( +
+ + {row.dimensions[0]} + + {typeof getExternalLinkUrl === 'function' && ( + + )} +
+ ) +} + +function ExternalLink({ + href, + isActive +}: { + href: string | null + isActive?: boolean +}) { + return ( +
+ {href && ( + + + + )} +
+ ) +} diff --git a/assets/js/dashboard/stats/modals/breakdown-table.tsx b/assets/js/dashboard/stats/modals/breakdown-table.tsx index 18ca9cde25a4..e8f0468681c3 100644 --- a/assets/js/dashboard/stats/modals/breakdown-table.tsx +++ b/assets/js/dashboard/stats/modals/breakdown-table.tsx @@ -2,13 +2,14 @@ import React, { ReactNode, useRef } from 'react' import { XMarkIcon } from '@heroicons/react/20/solid' import { SearchInput } from '../../components/search-input' -import { ColumnConfiguraton, Table } from '../../components/table-legacy' +import { Table } from '../../components/table' +import { ColumnConfiguration } from '../breakdowns' import RocketIcon from './rocket-icon' import { QueryStatus } from '@tanstack/react-query' import { useAppNavigate } from '../../navigation/use-app-navigate' import { rootRoute } from '../../router' -export const BreakdownTable = ({ +export function BreakdownTable({ title, isPending, isFetching, @@ -20,8 +21,9 @@ export const BreakdownTable = ({ data, status, error, - displayError, - onClose + displayError = true, + onClose, + getRowKey }: { title: ReactNode onSearch?: (input: string) => void @@ -30,14 +32,15 @@ export const BreakdownTable = ({ hasNextPage: boolean isFetchingNextPage: boolean fetchNextPage: () => void - columns: ColumnConfiguraton[] - data?: { pages: TListItem[][] } + columns: ColumnConfiguration[] | null + data?: { pages: T[][] } status?: QueryStatus error?: Error | null /** Controls whether the component displays API request errors or ignores them. */ displayError?: boolean onClose?: () => void -}) => { + getRowKey: (row: T) => string +}) { const searchRef = useRef(null) const navigate = useAppNavigate() const handleClose = @@ -50,7 +53,7 @@ export const BreakdownTable = ({

{title}

- {!!onSearch && ( + {typeof onSearch === 'function' && ( ({
{displayError && status === 'error' && } {isPending && } - {data && data={data} columns={columns} />} + {columns && data && ( + + )} {!isPending && !isFetching && hasNextPage && ( fetchNextPage()} From 854478d9ebd0ba29b3480fc791be6b7241b95340 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Sun, 19 Apr 2026 18:41:29 +0100 Subject: [PATCH 10/40] rename breakdown-modal -> details-breakdown --- .../stats/modals/{breakdown-modal.tsx => details-breakdown.tsx} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename assets/js/dashboard/stats/modals/{breakdown-modal.tsx => details-breakdown.tsx} (100%) diff --git a/assets/js/dashboard/stats/modals/breakdown-modal.tsx b/assets/js/dashboard/stats/modals/details-breakdown.tsx similarity index 100% rename from assets/js/dashboard/stats/modals/breakdown-modal.tsx rename to assets/js/dashboard/stats/modals/details-breakdown.tsx From cbd784a596e3830aa165baeef5cd040fd7a8cd10 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Sun, 19 Apr 2026 18:44:31 +0100 Subject: [PATCH 11/40] create legacy copy of list.tsx --- .../dashboard/stats/behaviours/conversions.js | 2 +- assets/js/dashboard/stats/behaviours/props.js | 2 +- .../behaviours/special-goal-prop-breakdown.js | 2 +- assets/js/dashboard/stats/devices/index.js | 2 +- assets/js/dashboard/stats/locations/index.js | 2 +- assets/js/dashboard/stats/locations/map.tsx | 2 +- .../stats/modals/breakdown-modal-legacy.tsx | 2 +- assets/js/dashboard/stats/pages/index.js | 2 +- .../dashboard/stats/reports/list-legacy.tsx | 411 ++++++++++++++++++ assets/js/dashboard/stats/sources/index.js | 2 +- .../dashboard/stats/sources/referrer-list.js | 2 +- 11 files changed, 421 insertions(+), 10 deletions(-) create mode 100644 assets/js/dashboard/stats/reports/list-legacy.tsx diff --git a/assets/js/dashboard/stats/behaviours/conversions.js b/assets/js/dashboard/stats/behaviours/conversions.js index aa5ff34dc11a..a99841f99c4d 100644 --- a/assets/js/dashboard/stats/behaviours/conversions.js +++ b/assets/js/dashboard/stats/behaviours/conversions.js @@ -3,7 +3,7 @@ import * as api from '../../api' import * as url from '../../util/url' import * as metrics from '../reports/metrics' -import ListReport from '../reports/list' +import ListReport from '../reports/list-legacy' import { useSiteContext } from '../../site-context' import { useDashboardStateContext } from '../../dashboard-state-context' diff --git a/assets/js/dashboard/stats/behaviours/props.js b/assets/js/dashboard/stats/behaviours/props.js index 8f7f8b793057..769e5d030169 100644 --- a/assets/js/dashboard/stats/behaviours/props.js +++ b/assets/js/dashboard/stats/behaviours/props.js @@ -1,5 +1,5 @@ import React from 'react' -import ListReport, { MIN_HEIGHT } from '../reports/list' +import ListReport, { MIN_HEIGHT } from '../reports/list-legacy' import * as metrics from '../reports/metrics' import * as api from '../../api' import * as url from '../../util/url' diff --git a/assets/js/dashboard/stats/behaviours/special-goal-prop-breakdown.js b/assets/js/dashboard/stats/behaviours/special-goal-prop-breakdown.js index 700b92042512..b82ef8368f65 100644 --- a/assets/js/dashboard/stats/behaviours/special-goal-prop-breakdown.js +++ b/assets/js/dashboard/stats/behaviours/special-goal-prop-breakdown.js @@ -1,5 +1,5 @@ import React from 'react' -import ListReport from '../reports/list' +import ListReport from '../reports/list-legacy' import * as metrics from '../reports/metrics' import * as url from '../../util/url' import * as api from '../../api' diff --git a/assets/js/dashboard/stats/devices/index.js b/assets/js/dashboard/stats/devices/index.js index f383821c2f00..88757a15c346 100644 --- a/assets/js/dashboard/stats/devices/index.js +++ b/assets/js/dashboard/stats/devices/index.js @@ -5,7 +5,7 @@ import { hasConversionGoalFilter, isFilteringOnFixedValue } from '../../util/filters' -import ListReport from '../reports/list' +import ListReport from '../reports/list-legacy' import * as metrics from '../reports/metrics' import * as api from '../../api' import * as url from '../../util/url' diff --git a/assets/js/dashboard/stats/locations/index.js b/assets/js/dashboard/stats/locations/index.js index 1421f4d42198..b083a2da1625 100644 --- a/assets/js/dashboard/stats/locations/index.js +++ b/assets/js/dashboard/stats/locations/index.js @@ -5,7 +5,7 @@ import CountriesMap from './map' import * as api from '../../api' import { apiPath } from '../../util/url' -import ListReport from '../reports/list' +import ListReport from '../reports/list-legacy' import * as metrics from '../reports/metrics' import { hasConversionGoalFilter, diff --git a/assets/js/dashboard/stats/locations/map.tsx b/assets/js/dashboard/stats/locations/map.tsx index 33dd356aace8..60b0bdfddaf2 100644 --- a/assets/js/dashboard/stats/locations/map.tsx +++ b/assets/js/dashboard/stats/locations/map.tsx @@ -17,7 +17,7 @@ import { useDashboardStateContext } from '../../dashboard-state-context' import worldJson from 'visionscarto-world-atlas/world/110m.json' import { UIMode, useTheme } from '../../theme-context' import { apiPath } from '../../util/url' -import { MIN_HEIGHT } from '../reports/list' +import { MIN_HEIGHT } from '../reports/list-legacy' import { MapTooltip } from './map-tooltip' import { GeolocationNotice } from './geolocation-notice' import { DashboardState } from '../../dashboard-state' diff --git a/assets/js/dashboard/stats/modals/breakdown-modal-legacy.tsx b/assets/js/dashboard/stats/modals/breakdown-modal-legacy.tsx index 12e5bb0a997b..7189784fc3df 100644 --- a/assets/js/dashboard/stats/modals/breakdown-modal-legacy.tsx +++ b/assets/js/dashboard/stats/modals/breakdown-modal-legacy.tsx @@ -17,7 +17,7 @@ import { ColumnConfiguraton } from '../../components/table-legacy' import { BreakdownTable } from './breakdown-table-legacy' import { useSiteContext } from '../../site-context' import { DrilldownLink, FilterInfo } from '../../components/drilldown-link' -import { SharedReportProps } from '../reports/list' +import { SharedReportProps } from '../reports/list-legacy' import { hasConversionGoalFilter } from '../../util/filters' export type ReportInfo = { diff --git a/assets/js/dashboard/stats/pages/index.js b/assets/js/dashboard/stats/pages/index.js index 962cb935a5ae..3a07cd81d4c5 100644 --- a/assets/js/dashboard/stats/pages/index.js +++ b/assets/js/dashboard/stats/pages/index.js @@ -3,7 +3,7 @@ import React, { useEffect, useState } from 'react' import * as storage from '../../util/storage' import * as url from '../../util/url' import * as api from '../../api' -import ListReport from './../reports/list' +import ListReport from '../reports/list-legacy' import * as metrics from './../reports/metrics' import ImportedQueryUnsupportedWarning from '../imported-query-unsupported-warning' import { hasConversionGoalFilter } from '../../util/filters' diff --git a/assets/js/dashboard/stats/reports/list-legacy.tsx b/assets/js/dashboard/stats/reports/list-legacy.tsx new file mode 100644 index 000000000000..43810bbdf4c2 --- /dev/null +++ b/assets/js/dashboard/stats/reports/list-legacy.tsx @@ -0,0 +1,411 @@ +import React, { useState, useEffect, useCallback, ReactNode } from 'react' +import FlipMove from 'react-flip-move' + +import FadeIn from '../../fade-in' +import Bar from '../bar' +import LazyLoader from '../../components/lazy-loader' +import { trimURL } from '../../util/url' +import { + isRealTimeDashboard, + hasConversionGoalFilter +} from '../../util/filters' +import { useDashboardStateContext } from '../../dashboard-state-context' +import { Metric } from './metrics' +import { DrilldownLink, FilterInfo } from '../../components/drilldown-link' +import { BreakdownResultMeta } from '../../dashboard-state' + +const MAX_ITEMS = 9 +export const MIN_HEIGHT = 356 +const ROW_HEIGHT = 32 +const ROW_GAP_HEIGHT = 4 +const DATA_CONTAINER_HEIGHT = + (ROW_HEIGHT + ROW_GAP_HEIGHT) * (MAX_ITEMS - 1) + ROW_HEIGHT +const COL_MIN_WIDTH = 70 + +function ExternalLink({ + item, + getExternalLinkUrl, + isTapped +}: { + item: T + getExternalLinkUrl?: (item: T) => string + isTapped?: boolean +}) { + const dest = getExternalLinkUrl && getExternalLinkUrl(item) + if (dest) { + const className = isTapped + ? 'visible md:invisible md:group-hover/row:visible' + : 'invisible md:group-hover/row:visible' + + return ( + + + + + + ) + } + + return null +} + +export interface SharedReportProps< + TListItem extends Record = Record, + TResponse = { results: TListItem[]; meta: BreakdownResultMeta } +> { + metrics: Metric[] + /** A function that takes a list item and returns the filter + * that should be applied when the list item is clicked. All existing filters matching prefix + * are removed. If a list item is not supposed to be clickable, this function should + * return `null` for that list item. */ + getFilterInfo: (item: TListItem) => FilterInfo | null + /** A function that takes a list item and returns the HTML of an icon */ + renderIcon?: (item: TListItem) => ReactNode + /** A function that takes a list item and returns an external URL + * to navigate to. If this prop is given, an additional icon is rendered upon hovering + * the entry. */ + getExternalLinkUrl?: (item: TListItem) => string + /** A function that defines the data + * to be rendered, and should return a list of objects under a `results` key. Think of + * these objects as rows. The number of columns that are **actually rendered** is also + * configurable through the `metrics` prop, which also defines the keys under which + * column values are read, and how they're rendered. */ + fetchData: () => Promise + afterFetchData?: (response: TResponse) => void + afterFetchNextPage?: (response: TResponse) => void +} + +type ListReportProps = { + /** What each entry in the list represents (for UI only). */ + keyLabel: string + metrics: Metric[] + colMinWidth?: number + /** Function with additional action to be taken when a list entry is clicked. */ + onClick?: () => void + /** Color of the comparison bars in light-mode. */ + color?: string +} + +/** + * @returns {HTMLElement} Table of metrics, in the following format: + * | keyLabel | METRIC_1.renderLabel(dashboardState) | METRIC_2.renderLabel(dashboardState) | ... + * |--------------------|--------------------------------------|--------------------------------------| --- + * | LISTITEM_1.name | LISTITEM_1[METRIC_1.key] | LISTITEM_1[METRIC_2.key] | ... + * | LISTITEM_2.name | LISTITEM_2[METRIC_1.key] | LISTITEM_2[METRIC_2.key] | ... + */ +export default function ListReport< + TListItem extends Record & { name: string } +>({ + keyLabel, + metrics, + colMinWidth = COL_MIN_WIDTH, + afterFetchData, + onClick, + color, + getFilterInfo, + renderIcon, + getExternalLinkUrl, + fetchData +}: Omit, 'afterFetchNextPage'> & ListReportProps) { + const { dashboardState } = useDashboardStateContext() + const [state, setState] = useState<{ + loading: boolean + list: TListItem[] | null + meta: BreakdownResultMeta | null + }>({ loading: true, list: null, meta: null }) + const [visible, setVisible] = useState(false) + const [tappedRow, setTappedRow] = useState(null) + + const isRealtime = isRealTimeDashboard(dashboardState) + const goalFilterApplied = hasConversionGoalFilter(dashboardState) + + const getData = useCallback(() => { + if (!isRealtime) { + setState({ loading: true, list: null, meta: null }) + } + fetchData().then((response) => { + if (afterFetchData) { + afterFetchData(response) + } + + setState({ loading: false, list: response.results, meta: response.meta }) + }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [keyLabel, dashboardState]) + + const onVisible = () => { + setVisible(true) + } + + useEffect(() => { + if (isRealtime) { + // When a goal filter is applied or removed, we always want the component to go into a + // loading state, even in realtime mode, because the metrics list will change. We can + // only read the new metrics once the new list is loaded. + setState({ loading: true, list: null, meta: null }) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [goalFilterApplied]) + + useEffect(() => { + if (visible) { + if (isRealtime) { + document.addEventListener('tick', getData) + } + getData() + } + + return () => { + document.removeEventListener('tick', getData) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [keyLabel, dashboardState, visible]) + + // returns a filtered `metrics` list. Since currently, the backend can return different + // metrics based on filters and existing data, this function validates that the metrics + // we want to display are actually there in the API response. + function getAvailableMetrics() { + return metrics.filter((metric) => + state.list + ? state.list.some((listItem) => listItem[metric.key] != null) + : false + ) + } + + function hiddenOnMobileClass(metric: Metric) { + if (metric.meta.hiddenOnMobile) { + return 'hidden md:block' + } else { + return '' + } + } + + function showOnHoverClass(metric: Metric, listItemName: string) { + if (!metric.meta.showOnHover) { + return '' + } + + // On mobile: show if row is tapped, hide otherwise + // On desktop: slide in from right when hovering + if (tappedRow === listItemName) { + return 'translate-x-0 opacity-100 transition-all duration-150' + } else { + return 'translate-x-[100%] opacity-0 transition-all duration-150 md:group-hover/report:translate-x-0 md:group-hover/report:opacity-100' + } + } + + function slideLeftClass( + metricIndex: number, + showOnHoverIndex: number, + hasShowOnHoverMetric: boolean, + listItemName: string + ) { + // Columns before the showOnHover column should slide left when it appears + if (!hasShowOnHoverMetric || metricIndex >= showOnHoverIndex) { + return '' + } + + if (tappedRow === listItemName) { + return 'transition-transform duration-150 translate-x-0' + } else { + return 'transition-transform duration-150 translate-x-[100%] md:group-hover/report:translate-x-0' + } + } + + function renderReport() { + if (state.list && state.list.length > 0) { + return ( +
+
{renderReportHeader()}
+ +
+ + {state.list.slice(0, MAX_ITEMS).map(renderRow)} + +
+
+ ) + } + return renderNoDataYet() + } + + function renderReportHeader() { + const metricLabels = getAvailableMetrics() + .filter((metric) => !metric.meta.showOnHover) + .map((metric) => { + return ( +
+ {metric.renderLabel(dashboardState)} +
+ ) + }) + + return ( +
+ + {keyLabel} + + {metricLabels} +
+ ) + } + + function renderRow(listItem: TListItem) { + const handleRowClick = (e: React.MouseEvent) => { + if (window.innerWidth < 768 && !(e.target as HTMLElement).closest('a')) { + if (tappedRow === listItem.name) { + setTappedRow(null) + } else { + setTappedRow(listItem.name) + } + } + } + + return ( +
+
+ {renderBarFor(listItem)} + {renderMetricValuesFor(listItem)} +
+
+ ) + } + + function renderBarFor(listItem: TListItem) { + const lightBackground = color || 'bg-green-50 group-hover/row:bg-green-100' + const metricToPlot = metrics.find((metric) => metric.meta.plot)?.key + + return ( +
+ +
+ + {maybeRenderIconFor(listItem)} + + + {trimURL(listItem.name, colMinWidth)} + + + +
+
+
+ ) + } + + function maybeRenderIconFor(listItem: TListItem) { + if (renderIcon) { + return renderIcon(listItem) + } + } + + function renderMetricValuesFor(listItem: TListItem) { + const availableMetrics = getAvailableMetrics() + const showOnHoverIndex = availableMetrics.findIndex( + (m) => m.meta.showOnHover + ) + const hasShowOnHoverMetric = showOnHoverIndex !== -1 + + return ( + <> + {availableMetrics.map((metric, index) => { + const isShowOnHover = metric.meta.showOnHover + + return ( +
+ + {metric.renderValue(listItem, state.meta, { + detailedView: false, + isRowHovered: false + })} + +
+ ) + })} + + ) + } + + function renderLoading() { + return ( +
+
+
+
+
+ ) + } + + function renderNoDataYet() { + return ( +
+
+ No data yet +
+
+ ) + } + + return ( + +
+ {state.loading && renderLoading()} + {!state.loading && ( + + {renderReport()} + + )} +
+
+ ) +} diff --git a/assets/js/dashboard/stats/sources/index.js b/assets/js/dashboard/stats/sources/index.js index 48b857425540..8a63182bfeb2 100644 --- a/assets/js/dashboard/stats/sources/index.js +++ b/assets/js/dashboard/stats/sources/index.js @@ -4,7 +4,7 @@ import * as storage from '../../util/storage' import * as url from '../../util/url' import * as api from '../../api' import usePrevious from '../../hooks/use-previous' -import ListReport from '../reports/list' +import ListReport from '../reports/list-legacy' import * as metrics from '../reports/metrics' import { getFiltersByKeyPrefix, diff --git a/assets/js/dashboard/stats/sources/referrer-list.js b/assets/js/dashboard/stats/sources/referrer-list.js index f6b6c76280c9..99b8a5a48f9f 100644 --- a/assets/js/dashboard/stats/sources/referrer-list.js +++ b/assets/js/dashboard/stats/sources/referrer-list.js @@ -3,7 +3,7 @@ import * as api from '../../api' import * as url from '../../util/url' import * as metrics from '../reports/metrics' import { hasConversionGoalFilter } from '../../util/filters' -import ListReport from '../reports/list' +import ListReport from '../reports/list-legacy' import { useDashboardStateContext } from '../../dashboard-state-context' import { useSiteContext } from '../../site-context' import { SourceFavicon } from './source-favicon' From 6e39eca2c6dfb692caad6dc30bcdffaa904435e6 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Sun, 19 Apr 2026 18:50:14 +0100 Subject: [PATCH 12/40] add v2 version of ListReport --- assets/js/dashboard/stats/reports/list.tsx | 817 ++++++++++++--------- 1 file changed, 475 insertions(+), 342 deletions(-) diff --git a/assets/js/dashboard/stats/reports/list.tsx b/assets/js/dashboard/stats/reports/list.tsx index 43810bbdf4c2..2e1b61bd19d9 100644 --- a/assets/js/dashboard/stats/reports/list.tsx +++ b/assets/js/dashboard/stats/reports/list.tsx @@ -1,18 +1,34 @@ -import React, { useState, useEffect, useCallback, ReactNode } from 'react' +import React, { useCallback, useMemo, useState } from 'react' import FlipMove from 'react-flip-move' - import FadeIn from '../../fade-in' -import Bar from '../bar' import LazyLoader from '../../components/lazy-loader' import { trimURL } from '../../util/url' -import { - isRealTimeDashboard, - hasConversionGoalFilter -} from '../../util/filters' import { useDashboardStateContext } from '../../dashboard-state-context' -import { Metric } from './metrics' +import { useSiteContext } from '../../site-context' +import { usePaginatedQueryAPI } from '../../hooks/api-client' +import { createStatsQuery } from '../../stats-query' +import type { StatsQuery } from '../../stats-query' +import { Metric, getBreakdownMetricLabel } from '../metrics' +import { + ColumnConfiguration, + ExternalLinkIcon, + MetricValueTooltipContent, + SharedBreakdownReportProps, + formatDateRangeLabel, + useBodyPortalRef, + extractMetricValue +} from '../breakdowns' import { DrilldownLink, FilterInfo } from '../../components/drilldown-link' -import { BreakdownResultMeta } from '../../dashboard-state' +import * as api from '../../api' +import type { QueryResultRow, QueryResultQuery } from '../../api' +import classNames from 'classnames' +import { Tooltip } from '../../util/tooltip' +import { ChangeArrow } from './change-arrow' +import { MetricFormatterShort, MetricFormatterLong } from './metric-formatter' +import { + hasConversionGoalFilter, + isRealTimeDashboard +} from '../../util/filters' const MAX_ITEMS = 9 export const MIN_HEIGHT = 356 @@ -20,370 +36,410 @@ const ROW_HEIGHT = 32 const ROW_GAP_HEIGHT = 4 const DATA_CONTAINER_HEIGHT = (ROW_HEIGHT + ROW_GAP_HEIGHT) * (MAX_ITEMS - 1) + ROW_HEIGHT -const COL_MIN_WIDTH = 70 - -function ExternalLink({ - item, - getExternalLinkUrl, - isTapped -}: { - item: T - getExternalLinkUrl?: (item: T) => string - isTapped?: boolean -}) { - const dest = getExternalLinkUrl && getExternalLinkUrl(item) - if (dest) { - const className = isTapped - ? 'visible md:invisible md:group-hover/row:visible' - : 'invisible md:group-hover/row:visible' - - return ( - - - - - - ) - } - return null -} +const MAX_DIMENSION_LENGTH = 70 -export interface SharedReportProps< - TListItem extends Record = Record, - TResponse = { results: TListItem[]; meta: BreakdownResultMeta } -> { - metrics: Metric[] - /** A function that takes a list item and returns the filter - * that should be applied when the list item is clicked. All existing filters matching prefix - * are removed. If a list item is not supposed to be clickable, this function should - * return `null` for that list item. */ - getFilterInfo: (item: TListItem) => FilterInfo | null - /** A function that takes a list item and returns the HTML of an icon */ - renderIcon?: (item: TListItem) => ReactNode - /** A function that takes a list item and returns an external URL - * to navigate to. If this prop is given, an additional icon is rendered upon hovering - * the entry. */ - getExternalLinkUrl?: (item: TListItem) => string - /** A function that defines the data - * to be rendered, and should return a list of objects under a `results` key. Think of - * these objects as rows. The number of columns that are **actually rendered** is also - * configurable through the `metrics` prop, which also defines the keys under which - * column values are read, and how they're rendered. */ - fetchData: () => Promise - afterFetchData?: (response: TResponse) => void - afterFetchNextPage?: (response: TResponse) => void -} +const DEFAULT_METRIC_COLUMN_WIDTH = 'w-16 min-w-16' +const VISITORS_WITH_PERCENTAGE_COLUMN_WIDTH = 'w-32 min-w-32' -type ListReportProps = { - /** What each entry in the list represents (for UI only). */ - keyLabel: string - metrics: Metric[] - colMinWidth?: number - /** Function with additional action to be taken when a list entry is clicked. */ - onClick?: () => void - /** Color of the comparison bars in light-mode. */ - color?: string -} +const BAR_METRIC = 'visitors' -/** - * @returns {HTMLElement} Table of metrics, in the following format: - * | keyLabel | METRIC_1.renderLabel(dashboardState) | METRIC_2.renderLabel(dashboardState) | ... - * |--------------------|--------------------------------------|--------------------------------------| --- - * | LISTITEM_1.name | LISTITEM_1[METRIC_1.key] | LISTITEM_1[METRIC_2.key] | ... - * | LISTITEM_2.name | LISTITEM_2[METRIC_1.key] | LISTITEM_2[METRIC_2.key] | ... - */ -export default function ListReport< - TListItem extends Record & { name: string } ->({ - keyLabel, +export function IndexBreakdown({ metrics, - colMinWidth = COL_MIN_WIDTH, - afterFetchData, - onClick, + dimensions, color, getFilterInfo, - renderIcon, getExternalLinkUrl, - fetchData -}: Omit, 'afterFetchNextPage'> & ListReportProps) { + dimensionLabel, + afterFetchData, + metricColumnWidth = DEFAULT_METRIC_COLUMN_WIDTH +}: SharedBreakdownReportProps & { color: string; metricColumnWidth?: string }) { + const site = useSiteContext() const { dashboardState } = useDashboardStateContext() - const [state, setState] = useState<{ - loading: boolean - list: TListItem[] | null - meta: BreakdownResultMeta | null - }>({ loading: true, list: null, meta: null }) const [visible, setVisible] = useState(false) - const [tappedRow, setTappedRow] = useState(null) - - const isRealtime = isRealTimeDashboard(dashboardState) - const goalFilterApplied = hasConversionGoalFilter(dashboardState) + const [query, setQuery] = useState(null) - const getData = useCallback(() => { - if (!isRealtime) { - setState({ loading: true, list: null, meta: null }) - } - fetchData().then((response) => { - if (afterFetchData) { - afterFetchData(response) - } - - setState({ loading: false, list: response.results, meta: response.meta }) - }) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [keyLabel, dashboardState]) + const statsQuery: StatsQuery = useMemo( + () => createStatsQuery(dashboardState, { metrics: metrics, dimensions }), + [dashboardState, metrics, dimensions] + ) - const onVisible = () => { - setVisible(true) - } + const handleAfterFetchData = useCallback( + (response: api.QueryApiResponse) => { + setQuery(response.query) + afterFetchData?.(response) + }, + [afterFetchData] + ) - useEffect(() => { - if (isRealtime) { - // When a goal filter is applied or removed, we always want the component to go into a - // loading state, even in realtime mode, because the metrics list will change. We can - // only read the new metrics once the new list is loaded. - setState({ loading: true, list: null, meta: null }) - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [goalFilterApplied]) - - useEffect(() => { - if (visible) { - if (isRealtime) { - document.addEventListener('tick', getData) - } - getData() - } - - return () => { - document.removeEventListener('tick', getData) - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [keyLabel, dashboardState, visible]) - - // returns a filtered `metrics` list. Since currently, the backend can return different - // metrics based on filters and existing data, this function validates that the metrics - // we want to display are actually there in the API response. - function getAvailableMetrics() { - return metrics.filter((metric) => - state.list - ? state.list.some((listItem) => listItem[metric.key] != null) - : false - ) - } + const apiState = usePaginatedQueryAPI({ + site, + statsQuery, + afterFetchData: handleAfterFetchData, + pageSize: MAX_ITEMS, + enabled: visible + }) + + const barMetricIndex = query + ? query.metrics.findIndex((m) => m === BAR_METRIC) + : null + + const barMaxValue = useMemo(() => { + const rows = apiState.data?.pages?.[0] ?? [] + return barMetricIndex === null + ? null + : Math.max(...rows.map((r) => r.metrics[barMetricIndex] as number)) + }, [apiState.data, barMetricIndex]) + + const metricLabelFor = useCallback( + (metric: Metric): string => { + return getBreakdownMetricLabel(metric, { + hasConversionGoalFilter: hasConversionGoalFilter(dashboardState), + isRealtime: isRealTimeDashboard(dashboardState), + dimensions: dimensions + }) + }, + [dashboardState, dimensions] + ) - function hiddenOnMobileClass(metric: Metric) { - if (metric.meta.hiddenOnMobile) { - return 'hidden md:block' - } else { - return '' - } - } + const columns = useMemo((): ColumnConfiguration[] | null => { + if (!query || barMetricIndex === null || barMaxValue === null) return null + + // Only render columns for metrics the API actually returned. Also, + // percentage is not its own column —- it's shown inline in the + // visitors cell instead. + const filteredMetrics = query.metrics.filter((m) => m !== 'percentage') + + const hasPercentage = query.metrics.includes('percentage') + const isVisitorsWithPercentageCell = (m: Metric) => + hasPercentage && m === 'visitors' + + return [ + { + key: 'dimension', + renderLabel: () => dimensionLabel, + renderCell: (row, isActive) => ( + + ), + align: 'left' + }, + ...filteredMetrics.map( + (metric): ColumnConfiguration => ({ + key: metric, + renderLabel: () => metricLabelFor(metric), + renderCell: (row, isActive) => { + if (isVisitorsWithPercentageCell(metric)) { + return ( + + ) + } else { + return ( + + ) + } + }, + width: isVisitorsWithPercentageCell(metric) + ? VISITORS_WITH_PERCENTAGE_COLUMN_WIDTH + : metricColumnWidth, + align: 'right' + }) + ) + ] + }, [ + dimensionLabel, + color, + barMetricIndex, + metricLabelFor, + barMaxValue, + query, + getFilterInfo, + getExternalLinkUrl, + metricColumnWidth + ]) - function showOnHoverClass(metric: Metric, listItemName: string) { - if (!metric.meta.showOnHover) { - return '' - } - - // On mobile: show if row is tapped, hide otherwise - // On desktop: slide in from right when hovering - if (tappedRow === listItemName) { - return 'translate-x-0 opacity-100 transition-all duration-150' - } else { - return 'translate-x-[100%] opacity-0 transition-all duration-150 md:group-hover/report:translate-x-0 md:group-hover/report:opacity-100' - } - } + return ( + setVisible(true)}> + + + ) +} - function slideLeftClass( - metricIndex: number, - showOnHoverIndex: number, - hasShowOnHoverMetric: boolean, - listItemName: string - ) { - // Columns before the showOnHover column should slide left when it appears - if (!hasShowOnHoverMetric || metricIndex >= showOnHoverIndex) { - return '' - } - - if (tappedRow === listItemName) { - return 'transition-transform duration-150 translate-x-0' - } else { - return 'transition-transform duration-150 translate-x-[100%] md:group-hover/report:translate-x-0' - } - } +function DimensionCell({ + row, + color, + barWidthPercent, + getFilterInfo, + getExternalLinkUrl, + isActive +}: { + row: QueryResultRow + color: string + barWidthPercent: number + getFilterInfo: (row: QueryResultRow) => FilterInfo | null + getExternalLinkUrl?: (row: QueryResultRow) => string | null + isActive?: boolean +}) { + const externalUrl = getExternalLinkUrl?.(row) + return ( +
+
+
+ + + {trimURL(row.dimensions[0], MAX_DIMENSION_LENGTH)} + + + {externalUrl && } +
+
+ ) +} - function renderReport() { - if (state.list && state.list.length > 0) { - return ( -
-
{renderReportHeader()}
- -
- - {state.list.slice(0, MAX_ITEMS).map(renderRow)} - -
-
- ) - } - return renderNoDataYet() - } +function VisitorsWithPercentageCell({ + row, + query, + isActive +}: { + row: QueryResultRow + query: QueryResultQuery + isActive?: boolean +}) { + const portalRef = useBodyPortalRef() + + const { value: visitorsValue, comparison: visitorsComparison } = + extractMetricValue(row, query, 'visitors') + const { value: percentageValue, comparison: percentageComparison } = + extractMetricValue(row, query, 'percentage') + + const visitorsShortFormatter = MetricFormatterShort['visitors'] + const visitorsLongFormatter = MetricFormatterLong['visitors'] + const percentageFormatter = MetricFormatterShort['percentage'] + + const isVisitorsAbbreviated = + visitorsValue !== null && + visitorsShortFormatter(visitorsValue) !== + visitorsLongFormatter(visitorsValue) + + const showVisitorsTooltip = !!visitorsComparison || isVisitorsAbbreviated + const showPercentageTooltip = !!percentageComparison + + const dateRangeLabel = formatDateRangeLabel(query.date_range) + const comparisonDateRangeLabel = query.comparison_date_range + ? formatDateRangeLabel(query.comparison_date_range) + : null + + const percentageCell = ( + + {percentageFormatter(percentageValue)} + {percentageComparison && ( + + )} + + ) - function renderReportHeader() { - const metricLabels = getAvailableMetrics() - .filter((metric) => !metric.meta.showOnHover) - .map((metric) => { - return ( -
- {metric.renderLabel(dashboardState)} -
- ) - }) + const percentageWithTooltip = showPercentageTooltip ? ( + } + info={ + + } + > + {percentageCell} + + ) : ( + percentageCell + ) - return ( -
- - {keyLabel} - - {metricLabels} -
- ) - } + const visitorsCell = ( + + {visitorsShortFormatter(visitorsValue)} + {visitorsComparison && ( + + )} + + ) - function renderRow(listItem: TListItem) { - const handleRowClick = (e: React.MouseEvent) => { - if (window.innerWidth < 768 && !(e.target as HTMLElement).closest('a')) { - if (tappedRow === listItem.name) { - setTappedRow(null) - } else { - setTappedRow(listItem.name) - } + const visitorsWithTooltip = showVisitorsTooltip ? ( + } + info={ + } - } + > + {visitorsCell} + + ) : ( + visitorsCell + ) - return ( -
-
- {renderBarFor(listItem)} - {renderMetricValuesFor(listItem)} -
-
- ) - } + return ( +
+
{visitorsWithTooltip}
+
{percentageWithTooltip}
+
+ ) +} - function renderBarFor(listItem: TListItem) { - const lightBackground = color || 'bg-green-50 group-hover/row:bg-green-100' - const metricToPlot = metrics.find((metric) => metric.meta.plot)?.key +function MetricValueCell({ + row, + metric, + metricLabel, + query +}: { + row: QueryResultRow + metric: Metric + metricLabel: string + query: QueryResultQuery +}) { + const portalRef = useBodyPortalRef() + + const { value, comparison } = extractMetricValue(row, query, metric) + + const shortFormatter = MetricFormatterShort[metric] + const longFormatter = MetricFormatterLong[metric] + + const isAbbreviated = + value !== null && shortFormatter(value) !== longFormatter(value) + const showTooltip = !!comparison || isAbbreviated + + const valueContent = ( + + {shortFormatter(value)} + {comparison && ( + + )} + + ) - return ( -
- -
- - {maybeRenderIconFor(listItem)} - - - {trimURL(listItem.name, colMinWidth)} - - - -
-
-
- ) - } + if (!showTooltip) return valueContent - function maybeRenderIconFor(listItem: TListItem) { - if (renderIcon) { - return renderIcon(listItem) - } - } + const dateRangeLabel = formatDateRangeLabel(query.date_range) + const comparisonDateRangeLabel = query.comparison_date_range + ? formatDateRangeLabel(query.comparison_date_range) + : null - function renderMetricValuesFor(listItem: TListItem) { - const availableMetrics = getAvailableMetrics() - const showOnHoverIndex = availableMetrics.findIndex( - (m) => m.meta.showOnHover - ) - const hasShowOnHoverMetric = showOnHoverIndex !== -1 + return ( + } + info={ + + } + > + {valueContent} + + ) +} - return ( - <> - {availableMetrics.map((metric, index) => { - const isShowOnHover = metric.meta.showOnHover +export function IndexBreakdownRenderer({ + apiState, + columns +}: { + apiState: ReturnType + columns: ColumnConfiguration[] | null +}) { + const [tappedRow, setTappedRow] = useState(null) - return ( -
- - {metric.renderValue(listItem, state.meta, { - detailedView: false, - isRowHovered: false - })} - -
- ) - })} - - ) - } + if (!columns) return null + const rows = apiState.data?.pages?.[0]?.slice(0, MAX_ITEMS) ?? [] - function renderLoading() { + if (apiState.isPending) { return (
-
+
) } - function renderNoDataYet() { + if (rows.length === 0) { return (
-
- {state.loading && renderLoading()} - {!state.loading && ( - - {renderReport()} - - )} + +
+
+ {columns.map((col) => ( +
+ {col.renderLabel()} +
+ ))} +
+
+ + {rows.map((row) => { + const dimension = row.dimensions[0] + const isActive = tappedRow === dimension + + const handleClick = (e: React.MouseEvent) => { + if ( + window.innerWidth < 768 && + !(e.target as HTMLElement).closest('a') + ) { + setTappedRow(isActive ? null : dimension) + } + } + + return ( +
+
+ {columns.map((col) => ( +
+ {col.renderCell(row, isActive)} +
+ ))} +
+
+ ) + })} +
+
- +
+ ) +} + +function ExternalLink({ + href, + isActive +}: { + href: string + isActive?: boolean +}) { + return ( + + + ) } From 19d8f4ff32b2aae77bdad901c1c3d90c00617cbf Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Sun, 19 Apr 2026 18:55:24 +0100 Subject: [PATCH 13/40] rename list.tsx -> index-breakdown.tsx --- .../js/dashboard/stats/reports/{list.tsx => index-breakdown.tsx} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename assets/js/dashboard/stats/reports/{list.tsx => index-breakdown.tsx} (100%) diff --git a/assets/js/dashboard/stats/reports/list.tsx b/assets/js/dashboard/stats/reports/index-breakdown.tsx similarity index 100% rename from assets/js/dashboard/stats/reports/list.tsx rename to assets/js/dashboard/stats/reports/index-breakdown.tsx From df4c79bacd753bdf9b1765dbf3649d42da49a220 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Sun, 19 Apr 2026 22:19:41 +0100 Subject: [PATCH 14/40] pages/entry-pages/exit-pages v2 --- assets/js/dashboard/router.tsx | 12 +- assets/js/dashboard/stats/breakdowns.tsx | 57 +++++++ .../js/dashboard/stats/modals/entry-pages.js | 102 ------------ .../js/dashboard/stats/modals/exit-pages.js | 105 ------------- .../js/dashboard/stats/pages/entry-pages.tsx | 106 +++++++++++++ .../js/dashboard/stats/pages/exit-pages.tsx | 105 +++++++++++++ assets/js/dashboard/stats/pages/index.js | 145 +----------------- assets/js/dashboard/stats/pages/pages.tsx | 108 +++++++++++++ assets/js/types/globals.d.ts | 1 + 9 files changed, 389 insertions(+), 352 deletions(-) delete mode 100644 assets/js/dashboard/stats/modals/entry-pages.js delete mode 100644 assets/js/dashboard/stats/modals/exit-pages.js create mode 100644 assets/js/dashboard/stats/pages/entry-pages.tsx create mode 100644 assets/js/dashboard/stats/pages/exit-pages.tsx create mode 100644 assets/js/dashboard/stats/pages/pages.tsx create mode 100644 assets/js/types/globals.d.ts diff --git a/assets/js/dashboard/router.tsx b/assets/js/dashboard/router.tsx index df0d7078510a..355bee142f32 100644 --- a/assets/js/dashboard/router.tsx +++ b/assets/js/dashboard/router.tsx @@ -11,9 +11,9 @@ import Dashboard from './index' import SourcesModal from './stats/modals/sources' import ReferrersDrilldownModal from './stats/modals/referrer-drilldown' import GoogleKeywordsModal from './stats/modals/google-keywords' -import PagesModal from './stats/modals/pages' -import EntryPagesModal from './stats/modals/entry-pages' -import ExitPagesModal from './stats/modals/exit-pages' +import { PagesDetails } from './stats/pages/pages' +import { EntryPagesDetails } from './stats/pages/entry-pages' +import { ExitPagesDetails } from './stats/pages/exit-pages' import LocationsModal from './stats/modals/locations-modal' import BrowsersModal from './stats/modals/devices/browsers-modal' import BrowserVersionsModal from './stats/modals/devices/browser-versions-modal' @@ -101,17 +101,17 @@ export const referrersGoogleRoute = { export const topPagesRoute = { path: 'pages', - element: + element: } export const entryPagesRoute = { path: 'entry-pages', - element: + element: } export const exitPagesRoute = { path: 'exit-pages', - element: + element: } export const countriesRoute = { diff --git a/assets/js/dashboard/stats/breakdowns.tsx b/assets/js/dashboard/stats/breakdowns.tsx index 2541439b1fcd..503fee250749 100644 --- a/assets/js/dashboard/stats/breakdowns.tsx +++ b/assets/js/dashboard/stats/breakdowns.tsx @@ -148,3 +148,60 @@ export function extractMetricValue( : null return { metricIndex, value, comparison } } + +const DEFAULT_DETAILED_METRICS = [ + 'visitors', + 'percentage', + 'bounce_rate', + 'visit_duration' +] as Metric[] + +export const getBreakdownMetrics = ({ + hasConversionGoalFilter, + isRealtime, + isDetailed = false, + isRevenueAvailable = false, + detailedMetrics = DEFAULT_DETAILED_METRICS +}: { + hasConversionGoalFilter: boolean + isRealtime: boolean + isDetailed?: boolean + isRevenueAvailable?: boolean + detailedMetrics?: Metric[] +}): Metric[] => { + if (hasConversionGoalFilter && isDetailed && isRevenueAvailable) { + return [ + 'total_visitors', + 'visitors', + 'group_conversion_rate', + 'total_revenue', + 'average_revenue' + ] + } + if (hasConversionGoalFilter && isDetailed) { + return ['total_visitors', 'visitors', 'group_conversion_rate'] + } + if (hasConversionGoalFilter) { + return ['visitors', 'group_conversion_rate'] + } + if (isRealtime) { + return ['visitors', 'percentage'] + } + if (isDetailed) { + return detailedMetrics + } + return ['visitors', 'percentage'] +} + +export function addDimensionSearchFilter( + statsQuery: StatsQuery, + dimension: string, + search: string +) { + return addFilter(statsQuery, [ + 'contains', + dimension, + [search], + { case_sensitive: false } + ] as ApiFilter) +} diff --git a/assets/js/dashboard/stats/modals/entry-pages.js b/assets/js/dashboard/stats/modals/entry-pages.js deleted file mode 100644 index 4affbffde5af..000000000000 --- a/assets/js/dashboard/stats/modals/entry-pages.js +++ /dev/null @@ -1,102 +0,0 @@ -import React, { useCallback } from 'react' -import Modal from './modal' -import { - hasConversionGoalFilter, - isRealTimeDashboard -} from '../../util/filters' -import { addFilter, revenueAvailable } from '../../dashboard-state' -import BreakdownModal from './breakdown-modal-legacy' -import * as metrics from '../reports/metrics' -import * as url from '../../util/url' -import { useDashboardStateContext } from '../../dashboard-state-context' -import { useSiteContext } from '../../site-context' -import { SortDirection } from '../../hooks/use-order-by-legacy' - -function EntryPagesModal() { - const { dashboardState } = useDashboardStateContext() - const site = useSiteContext() - - /*global BUILD_EXTRA*/ - const showRevenueMetrics = - BUILD_EXTRA && revenueAvailable(dashboardState, site) - - const reportInfo = { - title: 'Entry pages', - dimension: 'entry_page', - endpoint: url.apiPath(site, '/entry-pages'), - dimensionLabel: 'Entry page', - defaultOrder: ['visitors', SortDirection.desc] - } - - const getFilterInfo = useCallback( - (listItem) => { - return { - prefix: reportInfo.dimension, - filter: ['is', reportInfo.dimension, [listItem.name]] - } - }, - [reportInfo.dimension] - ) - - const addSearchFilter = useCallback( - (dashboardState, searchString) => { - return addFilter(dashboardState, [ - 'contains', - reportInfo.dimension, - [searchString], - { case_sensitive: false } - ]) - }, - [reportInfo.dimension] - ) - - function chooseMetrics() { - if (hasConversionGoalFilter(dashboardState)) { - return [ - metrics.createTotalVisitors(), - metrics.createVisitors({ - renderLabel: (_dashboardState) => 'Conversions', - width: 'w-28' - }), - metrics.createConversionRate(), - showRevenueMetrics && metrics.createTotalRevenue(), - showRevenueMetrics && metrics.createAverageRevenue() - ].filter((metric) => !!metric) - } - - if ( - isRealTimeDashboard(dashboardState) && - !hasConversionGoalFilter(dashboardState) - ) { - return [ - metrics.createVisitors({ - renderLabel: (_dashboardState) => 'Current visitors', - width: 'w-32' - }) - ] - } - - return [ - metrics.createVisitors({ renderLabel: (_dashboardState) => 'Visitors' }), - metrics.createVisits({ - renderLabel: (_dashboardState) => 'Total entrances', - width: 'w-32' - }), - metrics.createBounceRate(), - metrics.createVisitDuration() - ] - } - - return ( - - - - ) -} - -export default EntryPagesModal diff --git a/assets/js/dashboard/stats/modals/exit-pages.js b/assets/js/dashboard/stats/modals/exit-pages.js deleted file mode 100644 index ed80b47f93c1..000000000000 --- a/assets/js/dashboard/stats/modals/exit-pages.js +++ /dev/null @@ -1,105 +0,0 @@ -import React, { useCallback } from 'react' -import Modal from './modal' -import { - hasConversionGoalFilter, - isRealTimeDashboard -} from '../../util/filters' -import { addFilter, revenueAvailable } from '../../dashboard-state' -import BreakdownModal from './breakdown-modal-legacy' -import * as metrics from '../reports/metrics' -import * as url from '../../util/url' -import { useDashboardStateContext } from '../../dashboard-state-context' -import { useSiteContext } from '../../site-context' -import { SortDirection } from '../../hooks/use-order-by-legacy' - -function ExitPagesModal() { - const { dashboardState } = useDashboardStateContext() - const site = useSiteContext() - - /*global BUILD_EXTRA*/ - const showRevenueMetrics = - BUILD_EXTRA && revenueAvailable(dashboardState, site) - - const reportInfo = { - title: 'Exit pages', - dimension: 'exit_page', - endpoint: url.apiPath(site, '/exit-pages'), - dimensionLabel: 'Page url', - defaultOrder: ['visitors', SortDirection.desc] - } - - const getFilterInfo = useCallback( - (listItem) => { - return { - prefix: reportInfo.dimension, - filter: ['is', reportInfo.dimension, [listItem.name]] - } - }, - [reportInfo.dimension] - ) - - const addSearchFilter = useCallback( - (dashboardState, searchString) => { - return addFilter(dashboardState, [ - 'contains', - reportInfo.dimension, - [searchString], - { case_sensitive: false } - ]) - }, - [reportInfo.dimension] - ) - - function chooseMetrics() { - if (hasConversionGoalFilter(dashboardState)) { - return [ - metrics.createTotalVisitors(), - metrics.createVisitors({ - renderLabel: (_dashboardState) => 'Conversions', - width: 'w-28' - }), - metrics.createConversionRate(), - showRevenueMetrics && metrics.createTotalRevenue(), - showRevenueMetrics && metrics.createAverageRevenue() - ].filter((metric) => !!metric) - } - - if ( - isRealTimeDashboard(dashboardState) && - !hasConversionGoalFilter(dashboardState) - ) { - return [ - metrics.createVisitors({ - renderLabel: (_dashboardState) => 'Current visitors', - width: 'w-32' - }) - ] - } - - return [ - metrics.createVisitors({ - renderLabel: (_dashboardState) => 'Visitors', - sortable: true - }), - metrics.createVisits({ - renderLabel: (_dashboardState) => 'Total exits', - width: 'w-32', - sortable: true - }), - metrics.createExitRate() - ] - } - - return ( - - - - ) -} - -export default ExitPagesModal diff --git a/assets/js/dashboard/stats/pages/entry-pages.tsx b/assets/js/dashboard/stats/pages/entry-pages.tsx new file mode 100644 index 000000000000..abc828ed7bbc --- /dev/null +++ b/assets/js/dashboard/stats/pages/entry-pages.tsx @@ -0,0 +1,106 @@ +import React, { useCallback } from 'react' +import Modal from '../modals/modal' +import { Metric } from '../metrics' +import * as url from '../../util/url' +import { StatsQuery } from '../../stats-query' +import { IndexBreakdown } from '../reports/index-breakdown' +import { DetailsBreakdown } from '../modals/details-breakdown' +import { useDashboardStateContext } from '../../dashboard-state-context' +import { useSiteContext } from '../../site-context' +import { + hasConversionGoalFilter, + isRealTimeDashboard +} from '../../util/filters' +import { revenueAvailable, Filter } from '../../dashboard-state' +import { QueryApiResponse, QueryResultRow } from '../../api' +import { SortDirection } from '../../hooks/use-order-by' +import { addDimensionSearchFilter, getBreakdownMetrics } from '../breakdowns' +import { PAGES_BAR_COLOR } from './pages' + +const DIMENSION = 'visit:entry_page' +const DETAILED_METRICS: Metric[] = [ + 'visitors', + 'percentage', + 'visits', + 'bounce_rate', + 'visit_duration' +] + +function getFilterInfo(row: QueryResultRow) { + return { + prefix: 'entry_page', + filter: ['is', 'entry_page', [row.dimensions[0]]] as Filter + } +} + +function addSearchFilter(statsQuery: StatsQuery, search: string) { + return addDimensionSearchFilter(statsQuery, DIMENSION, search) +} + +export function EntryPagesIndex({ + afterFetchData +}: { + afterFetchData?: (response: QueryApiResponse) => void +}) { + const { dashboardState } = useDashboardStateContext() + const site = useSiteContext() + + const metrics = getBreakdownMetrics({ + hasConversionGoalFilter: hasConversionGoalFilter(dashboardState), + isRealtime: isRealTimeDashboard(dashboardState) + }) + + const getExternalLinkUrl = useCallback( + (row: QueryResultRow) => url.externalLinkForPage(site, row.dimensions[0]), + [site] + ) + + return ( + + ) +} + +export function EntryPagesDetails() { + const { dashboardState } = useDashboardStateContext() + const site = useSiteContext() + + /*global BUILD_EXTRA*/ + const isRevenueAvailable = + BUILD_EXTRA && revenueAvailable(dashboardState, site) + + const metrics = getBreakdownMetrics({ + hasConversionGoalFilter: hasConversionGoalFilter(dashboardState), + isRealtime: isRealTimeDashboard(dashboardState), + isDetailed: true, + isRevenueAvailable: isRevenueAvailable, + detailedMetrics: DETAILED_METRICS + }) + + const getExternalLinkUrl = useCallback( + (row: QueryResultRow) => url.externalLinkForPage(site, row.dimensions[0]), + [site] + ) + + return ( + + + + ) +} diff --git a/assets/js/dashboard/stats/pages/exit-pages.tsx b/assets/js/dashboard/stats/pages/exit-pages.tsx new file mode 100644 index 000000000000..ad50af996fd9 --- /dev/null +++ b/assets/js/dashboard/stats/pages/exit-pages.tsx @@ -0,0 +1,105 @@ +import React, { useCallback } from 'react' +import Modal from '../modals/modal' +import { Metric } from '../metrics' +import * as url from '../../util/url' +import { StatsQuery } from '../../stats-query' +import { IndexBreakdown } from '../reports/index-breakdown' +import { DetailsBreakdown } from '../modals/details-breakdown' +import { useDashboardStateContext } from '../../dashboard-state-context' +import { useSiteContext } from '../../site-context' +import { + hasConversionGoalFilter, + isRealTimeDashboard +} from '../../util/filters' +import { revenueAvailable, Filter } from '../../dashboard-state' +import { QueryApiResponse, QueryResultRow } from '../../api' +import { SortDirection } from '../../hooks/use-order-by' +import { addDimensionSearchFilter, getBreakdownMetrics } from '../breakdowns' +import { PAGES_BAR_COLOR } from './pages' + +const DIMENSION = 'visit:exit_page' +const DETAILED_METRICS: Metric[] = [ + 'visitors', + 'percentage', + 'visits', + 'exit_rate' +] + +function getFilterInfo(row: QueryResultRow) { + return { + prefix: 'exit_page', + filter: ['is', 'exit_page', [row.dimensions[0]]] as Filter + } +} + +function addSearchFilter(statsQuery: StatsQuery, search: string) { + return addDimensionSearchFilter(statsQuery, DIMENSION, search) +} + +export function ExitPagesIndex({ + afterFetchData +}: { + afterFetchData?: (response: QueryApiResponse) => void +}) { + const { dashboardState } = useDashboardStateContext() + const site = useSiteContext() + + const metrics = getBreakdownMetrics({ + hasConversionGoalFilter: hasConversionGoalFilter(dashboardState), + isRealtime: isRealTimeDashboard(dashboardState) + }) + + const getExternalLinkUrl = useCallback( + (row: QueryResultRow) => url.externalLinkForPage(site, row.dimensions[0]), + [site] + ) + + return ( + + ) +} + +export function ExitPagesDetails() { + const { dashboardState } = useDashboardStateContext() + const site = useSiteContext() + + /*global BUILD_EXTRA*/ + const isRevenueAvailable = + BUILD_EXTRA && revenueAvailable(dashboardState, site) + + const metrics = getBreakdownMetrics({ + hasConversionGoalFilter: hasConversionGoalFilter(dashboardState), + isRealtime: isRealTimeDashboard(dashboardState), + isDetailed: true, + isRevenueAvailable: isRevenueAvailable, + detailedMetrics: [...DETAILED_METRICS] + }) + + const getExternalLinkUrl = useCallback( + (row: QueryResultRow) => url.externalLinkForPage(site, row.dimensions[0]), + [site] + ) + + return ( + + + + ) +} diff --git a/assets/js/dashboard/stats/pages/index.js b/assets/js/dashboard/stats/pages/index.js index 3a07cd81d4c5..23682d253874 100644 --- a/assets/js/dashboard/stats/pages/index.js +++ b/assets/js/dashboard/stats/pages/index.js @@ -1,10 +1,6 @@ import React, { useEffect, useState } from 'react' import * as storage from '../../util/storage' -import * as url from '../../util/url' -import * as api from '../../api' -import ListReport from '../reports/list-legacy' -import * as metrics from './../reports/metrics' import ImportedQueryUnsupportedWarning from '../imported-query-unsupported-warning' import { hasConversionGoalFilter } from '../../util/filters' import { useDashboardStateContext } from '../../dashboard-state-context' @@ -15,138 +11,9 @@ import { ReportHeader } from '../reports/report-header' import { TabButton, TabWrapper } from '../../components/tabs' import MoreLink from '../more-link' import { MoreLinkState } from '../more-link-state' - -function EntryPages({ afterFetchData }) { - const { dashboardState } = useDashboardStateContext() - const site = useSiteContext() - function fetchData() { - return api.get(url.apiPath(site, '/entry-pages'), dashboardState, { - limit: 9 - }) - } - - function getExternalLinkUrl(page) { - return url.externalLinkForPage(site, page.name) - } - - function getFilterInfo(listItem) { - return { - prefix: 'entry_page', - filter: ['is', 'entry_page', [listItem['name']]] - } - } - - function chooseMetrics() { - return [ - metrics.createVisitors({ - defaultLabel: 'Unique entrances', - width: 'w-36', - meta: { plot: true } - }), - !hasConversionGoalFilter(dashboardState) && - metrics.createPercentage({ meta: { showOnHover: true } }), - hasConversionGoalFilter(dashboardState) && metrics.createConversionRate() - ].filter((metric) => !!metric) - } - - return ( - - ) -} - -function ExitPages({ afterFetchData }) { - const site = useSiteContext() - const { dashboardState } = useDashboardStateContext() - function fetchData() { - return api.get(url.apiPath(site, '/exit-pages'), dashboardState, { - limit: 9 - }) - } - - function getExternalLinkUrl(page) { - return url.externalLinkForPage(site, page.name) - } - - function getFilterInfo(listItem) { - return { - prefix: 'exit_page', - filter: ['is', 'exit_page', [listItem['name']]] - } - } - - function chooseMetrics() { - return [ - metrics.createVisitors({ - defaultLabel: 'Unique exits', - width: 'w-36', - meta: { plot: true } - }), - !hasConversionGoalFilter(dashboardState) && - metrics.createPercentage({ meta: { showOnHover: true } }), - hasConversionGoalFilter(dashboardState) && metrics.createConversionRate() - ].filter((metric) => !!metric) - } - - return ( - - ) -} - -function TopPages({ afterFetchData }) { - const { dashboardState } = useDashboardStateContext() - const site = useSiteContext() - function fetchData() { - return api.get(url.apiPath(site, '/pages'), dashboardState, { limit: 9 }) - } - - function getExternalLinkUrl(page) { - return url.externalLinkForPage(site, page.name) - } - - function getFilterInfo(listItem) { - return { - prefix: 'page', - filter: ['is', 'page', [listItem['name']]] - } - } - - function chooseMetrics() { - return [ - metrics.createVisitors({ meta: { plot: true } }), - !hasConversionGoalFilter(dashboardState) && - metrics.createPercentage({ meta: { showOnHover: true } }), - hasConversionGoalFilter(dashboardState) && metrics.createConversionRate() - ].filter((metric) => !!metric) - } - - return ( - - ) -} +import { PagesIndex } from './pages' +import { EntryPagesIndex } from './entry-pages' +import { ExitPagesIndex } from './exit-pages' export default function Pages() { const { dashboardState } = useDashboardStateContext() @@ -203,12 +70,12 @@ export default function Pages() { function renderContent() { switch (mode) { case 'entry-pages': - return + return case 'exit-pages': - return + return case 'pages': default: - return + return } } diff --git a/assets/js/dashboard/stats/pages/pages.tsx b/assets/js/dashboard/stats/pages/pages.tsx new file mode 100644 index 000000000000..d75c1805e9e5 --- /dev/null +++ b/assets/js/dashboard/stats/pages/pages.tsx @@ -0,0 +1,108 @@ +import React, { useCallback } from 'react' +import Modal from '../modals/modal' +import { Metric } from '../metrics' +import * as url from '../../util/url' +import { StatsQuery } from '../../stats-query' +import { IndexBreakdown } from '../reports/index-breakdown' +import { DetailsBreakdown } from '../modals/details-breakdown' +import { useDashboardStateContext } from '../../dashboard-state-context' +import { useSiteContext } from '../../site-context' +import { + hasConversionGoalFilter, + isRealTimeDashboard +} from '../../util/filters' +import { revenueAvailable, Filter } from '../../dashboard-state' +import { QueryApiResponse, QueryResultRow } from '../../api' +import { SortDirection } from '../../hooks/use-order-by' +import { addDimensionSearchFilter, getBreakdownMetrics } from '../breakdowns' + +export const PAGES_BAR_COLOR = 'bg-orange-50 group-hover/row:bg-orange-100' + +const DIMENSION = 'event:page' +const DETAILED_METRICS: Metric[] = [ + 'visitors', + 'percentage', + 'pageviews', + 'bounce_rate', + 'time_on_page', + 'scroll_depth' +] + +function getFilterInfo(row: QueryResultRow) { + return { + prefix: 'page', + filter: ['is', 'page', [row.dimensions[0]]] as Filter + } +} + +function addSearchFilter(statsQuery: StatsQuery, search: string) { + return addDimensionSearchFilter(statsQuery, DIMENSION, search) +} + +export function PagesIndex({ + afterFetchData +}: { + afterFetchData?: (response: QueryApiResponse) => void +}) { + const { dashboardState } = useDashboardStateContext() + const site = useSiteContext() + + const metrics = getBreakdownMetrics({ + hasConversionGoalFilter: hasConversionGoalFilter(dashboardState), + isRealtime: isRealTimeDashboard(dashboardState) + }) + + const getExternalLinkUrl = useCallback( + (row: QueryResultRow) => url.externalLinkForPage(site, row.dimensions[0]), + [site] + ) + + return ( + + ) +} + +export function PagesDetails() { + const { dashboardState } = useDashboardStateContext() + const site = useSiteContext() + + /*global BUILD_EXTRA*/ + const isRevenueAvailable = + BUILD_EXTRA && revenueAvailable(dashboardState, site) + + const metrics = getBreakdownMetrics({ + hasConversionGoalFilter: hasConversionGoalFilter(dashboardState), + isRealtime: isRealTimeDashboard(dashboardState), + isDetailed: true, + isRevenueAvailable: isRevenueAvailable, + detailedMetrics: DETAILED_METRICS + }) + + const getExternalLinkUrl = useCallback( + (row: QueryResultRow) => url.externalLinkForPage(site, row.dimensions[0]), + [site] + ) + + return ( + + + + ) +} diff --git a/assets/js/types/globals.d.ts b/assets/js/types/globals.d.ts new file mode 100644 index 000000000000..95a940397658 --- /dev/null +++ b/assets/js/types/globals.d.ts @@ -0,0 +1 @@ +declare const BUILD_EXTRA: boolean From 6daccaf6dfa20f4df7abdc5296d40619db8d9a75 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Tue, 28 Apr 2026 11:47:54 +0100 Subject: [PATCH 15/40] destructure apiState before passing into IndexBreakdownRenderer --- .../js/dashboard/stats/reports/index-breakdown.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/assets/js/dashboard/stats/reports/index-breakdown.tsx b/assets/js/dashboard/stats/reports/index-breakdown.tsx index 2e1b61bd19d9..aea3345cf8ec 100644 --- a/assets/js/dashboard/stats/reports/index-breakdown.tsx +++ b/assets/js/dashboard/stats/reports/index-breakdown.tsx @@ -177,7 +177,7 @@ export function IndexBreakdown({ return ( setVisible(true)}> - + ) } @@ -415,18 +415,20 @@ function MetricValueCell({ } export function IndexBreakdownRenderer({ - apiState, + data, + isPending, columns }: { - apiState: ReturnType + data?: { pages: QueryResultRow[][] } + isPending: boolean columns: ColumnConfiguration[] | null }) { const [tappedRow, setTappedRow] = useState(null) if (!columns) return null - const rows = apiState.data?.pages?.[0]?.slice(0, MAX_ITEMS) ?? [] + const rows = data?.pages?.[0]?.slice(0, MAX_ITEMS) ?? [] - if (apiState.isPending) { + if (isPending) { return (
Date: Tue, 28 Apr 2026 13:23:03 +0100 Subject: [PATCH 16/40] use staleTime in usePaginatedQueryAPI --- assets/js/dashboard/hooks/api-client.ts | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/assets/js/dashboard/hooks/api-client.ts b/assets/js/dashboard/hooks/api-client.ts index 59074126b32c..b49ea2c25b1e 100644 --- a/assets/js/dashboard/hooks/api-client.ts +++ b/assets/js/dashboard/hooks/api-client.ts @@ -41,6 +41,7 @@ type GetRequestParams = ( */ export function usePaginatedQueryAPI({ site, + dashboardState, statsQuery, afterFetchData, afterFetchNextPage, @@ -48,6 +49,7 @@ export function usePaginatedQueryAPI({ enabled = true }: { site: PlausibleSite + dashboardState: DashboardState statsQuery: StatsQuery afterFetchData?: (response: api.QueryApiResponse) => void afterFetchNextPage?: (response: api.QueryApiResponse) => void @@ -60,22 +62,14 @@ export function usePaginatedQueryAPI({ useEffect(() => { return () => { const tanstackQueryFilters: QueryFilters = { - predicate: (query) => { - const key = query.queryKey[0] - return ( - typeof key === 'object' && - key !== null && - 'dimensions' in key && - (key as StatsQuery).dimensions.join(',') === dimensionKey - ) - } + predicate: ({ queryKey }) => queryKey[0] === dimensionKey } queryClient.setQueriesData(tanstackQueryFilters, cleanToPageOne) } }, [queryClient, dimensionKey]) return useInfiniteQuery({ - queryKey: [statsQuery], + queryKey: [dimensionKey, statsQuery], enabled, queryFn: async ({ pageParam @@ -102,6 +96,12 @@ export function usePaginatedQueryAPI({ ? (lastPageParam as number) + pageSize : null }, + staleTime: () => + getStaleTime({ + siteTimezoneOffset: site.offset, + siteStatsBegin: site.statsBegin, + ...dashboardState + }), initialPageParam: 0, placeholderData: (previousData) => previousData }) From de6fff4636558e04121f7e78ceeaf6f6f0e35e0c Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Tue, 28 Apr 2026 13:51:42 +0100 Subject: [PATCH 17/40] Another iteration of improving v2 breakdown components - Cache whole api responses (including meta and query), not only results - AfterFetchData & afterFetchNextPage -> onDataReady. The function now also runs when fetch is a cache hit. - Avoid usePaginatedQueryAPI for IndexBreakdown. Unnecessary complexity because pagination is not needed. Instead let IndexBreakdown call useQuery directly --- assets/js/dashboard/hooks/api-client.ts | 41 +++--------- assets/js/dashboard/stats/breakdowns.tsx | 3 +- .../stats/modals/details-breakdown.tsx | 49 +++++++++----- .../js/dashboard/stats/pages/entry-pages.tsx | 6 +- .../js/dashboard/stats/pages/exit-pages.tsx | 6 +- assets/js/dashboard/stats/pages/index.js | 12 ++-- assets/js/dashboard/stats/pages/pages.tsx | 6 +- .../stats/reports/index-breakdown.tsx | 67 ++++++++++++------- 8 files changed, 98 insertions(+), 92 deletions(-) diff --git a/assets/js/dashboard/hooks/api-client.ts b/assets/js/dashboard/hooks/api-client.ts index b49ea2c25b1e..5086256d3296 100644 --- a/assets/js/dashboard/hooks/api-client.ts +++ b/assets/js/dashboard/hooks/api-client.ts @@ -35,26 +35,16 @@ type GetRequestParams = ( ) => [DashboardState, Record] /** - * Hook for paginated POST /api/stats/:domain/query requests. - * Pass pageSize to limit results (e.g. 9 for index views, default 100 for modals). - * Set enabled=false to defer fetching (e.g. until the component is visible). + * Hook for paginated POST /api/stats/:domain/query requests (i.e. Details views). */ export function usePaginatedQueryAPI({ site, dashboardState, - statsQuery, - afterFetchData, - afterFetchNextPage, - pageSize = PAGINATION_LIMIT, - enabled = true + statsQuery }: { site: PlausibleSite dashboardState: DashboardState statsQuery: StatsQuery - afterFetchData?: (response: api.QueryApiResponse) => void - afterFetchNextPage?: (response: api.QueryApiResponse) => void - pageSize?: number - enabled?: boolean }) { const queryClient = useQueryClient() const dimensionKey = statsQuery.dimensions.join(',') @@ -70,30 +60,15 @@ export function usePaginatedQueryAPI({ return useInfiniteQuery({ queryKey: [dimensionKey, statsQuery], - enabled, - queryFn: async ({ - pageParam - }): Promise => { - const response: api.QueryApiResponse = await api.stats(site, { + queryFn: async ({ pageParam }): Promise => { + return api.stats(site, { ...statsQuery, - pagination: { limit: pageSize, offset: pageParam as number } + pagination: { limit: PAGINATION_LIMIT, offset: pageParam as number } } as StatsQuery) - - if (pageParam === 0 && typeof afterFetchData === 'function') { - afterFetchData(response) - } - if ( - (pageParam as number) > 0 && - typeof afterFetchNextPage === 'function' - ) { - afterFetchNextPage(response) - } - - return response.results }, - getNextPageParam: (lastPageResults, _, lastPageParam) => { - return lastPageResults.length === pageSize - ? (lastPageParam as number) + pageSize + getNextPageParam: (lastPage, _, lastPageParam) => { + return lastPage.results.length === PAGINATION_LIMIT + ? (lastPageParam as number) + PAGINATION_LIMIT : null }, staleTime: () => diff --git a/assets/js/dashboard/stats/breakdowns.tsx b/assets/js/dashboard/stats/breakdowns.tsx index 503fee250749..71856428a4a3 100644 --- a/assets/js/dashboard/stats/breakdowns.tsx +++ b/assets/js/dashboard/stats/breakdowns.tsx @@ -1,6 +1,6 @@ import React, { ReactNode, useEffect, useRef } from 'react' import { SortDirection } from '../hooks/use-order-by' -import type { QueryResultRow, QueryResultQuery, QueryApiResponse } from '../api' +import type { QueryResultRow, QueryResultQuery } from '../api' import { Metric } from './metrics' import { FilterInfo } from '../components/drilldown-link' import { ChangeArrow } from './reports/change-arrow' @@ -14,7 +14,6 @@ export type SharedBreakdownReportProps = { metrics: Metric[] getFilterInfo: (row: QueryResultRow) => FilterInfo | null getExternalLinkUrl?: (row: QueryResultRow) => string | null - afterFetchData?: (response: QueryApiResponse) => void } export type ColumnConfiguration = { diff --git a/assets/js/dashboard/stats/modals/details-breakdown.tsx b/assets/js/dashboard/stats/modals/details-breakdown.tsx index 0b59ddef814d..58433976d31b 100644 --- a/assets/js/dashboard/stats/modals/details-breakdown.tsx +++ b/assets/js/dashboard/stats/modals/details-breakdown.tsx @@ -1,4 +1,10 @@ -import React, { ReactNode, useCallback, useMemo, useState } from 'react' +import React, { + ReactNode, + useCallback, + useEffect, + useMemo, + useState +} from 'react' import { useDashboardStateContext } from '../../dashboard-state-context' import { usePaginatedQueryAPI } from '../../hooks/api-client' import { rootRoute } from '../../router' @@ -43,11 +49,13 @@ import { } from '../../util/filters' import { SortButton } from '../../components/sort-button' +type PaginatedData = { pages: QueryApiResponse[] } + type DetailsBreakdownProps = SharedBreakdownReportProps & { title: ReactNode defaultOrderBy?: OrderBy addSearchFilter?: (statsQuery: StatsQuery, search: string) => StatsQuery - afterFetchNextPage?: (response: QueryApiResponse) => void + onDataReady?: (data: PaginatedData) => void } const getMetricCellWidthClass = ( @@ -78,14 +86,11 @@ export function DetailsBreakdown({ getFilterInfo, getExternalLinkUrl, addSearchFilter, - afterFetchData, - afterFetchNextPage + onDataReady }: DetailsBreakdownProps) { const site = useSiteContext() const { dashboardState } = useDashboardStateContext() const [search, setSearch] = useState('') - const [meta, setMeta] = useState(null) - const [query, setQuery] = useState(null) const storedOrderBy = getStoredOrderBy({ domain: site.domain, @@ -124,22 +129,25 @@ export function DetailsBreakdown({ return baseStatsQuery }, [baseStatsQuery, search, addSearchFilter]) - const handleAfterFetchData = useCallback( - (response: QueryApiResponse) => { - setMeta(response.meta) - setQuery(response.query) - afterFetchData?.(response) - }, - [afterFetchData] - ) - const apiState = usePaginatedQueryAPI({ site, - statsQuery, - afterFetchData: handleAfterFetchData, - afterFetchNextPage + dashboardState, + statsQuery }) + useEffect(() => { + const pages = apiState.data?.pages + if (pages?.length) { + onDataReady?.({ pages }) + } + }, [apiState.data, onDataReady]) + + const query: QueryResultQuery | null = + apiState.data?.pages?.[0]?.query ?? null + + const meta: QueryResultMeta | null = + (apiState.data?.pages?.[0]?.meta as QueryResultMeta) ?? null + const metricLabelFor = useCallback( (metric: Metric): string => { return getBreakdownMetricLabel(metric, { @@ -230,10 +238,15 @@ export function DetailsBreakdown({ metricLabelFor ]) + const tableData = apiState.data + ? { pages: apiState.data.pages.map((p) => p.results) } + : undefined + return ( title={title} {...apiState} + data={tableData} columns={columns} onSearch={addSearchFilter ? setSearch : undefined} getRowKey={(row) => row.dimensions[0]} diff --git a/assets/js/dashboard/stats/pages/entry-pages.tsx b/assets/js/dashboard/stats/pages/entry-pages.tsx index abc828ed7bbc..35fa97194439 100644 --- a/assets/js/dashboard/stats/pages/entry-pages.tsx +++ b/assets/js/dashboard/stats/pages/entry-pages.tsx @@ -38,9 +38,9 @@ function addSearchFilter(statsQuery: StatsQuery, search: string) { } export function EntryPagesIndex({ - afterFetchData + onDataReady }: { - afterFetchData?: (response: QueryApiResponse) => void + onDataReady?: (data: QueryApiResponse) => void }) { const { dashboardState } = useDashboardStateContext() const site = useSiteContext() @@ -63,7 +63,7 @@ export function EntryPagesIndex({ getExternalLinkUrl={getExternalLinkUrl} getFilterInfo={getFilterInfo} dimensionLabel="Entry page" - afterFetchData={afterFetchData} + onDataReady={onDataReady} /> ) } diff --git a/assets/js/dashboard/stats/pages/exit-pages.tsx b/assets/js/dashboard/stats/pages/exit-pages.tsx index ad50af996fd9..71625afbaca5 100644 --- a/assets/js/dashboard/stats/pages/exit-pages.tsx +++ b/assets/js/dashboard/stats/pages/exit-pages.tsx @@ -37,9 +37,9 @@ function addSearchFilter(statsQuery: StatsQuery, search: string) { } export function ExitPagesIndex({ - afterFetchData + onDataReady }: { - afterFetchData?: (response: QueryApiResponse) => void + onDataReady?: (data: QueryApiResponse) => void }) { const { dashboardState } = useDashboardStateContext() const site = useSiteContext() @@ -62,7 +62,7 @@ export function ExitPagesIndex({ getExternalLinkUrl={getExternalLinkUrl} getFilterInfo={getFilterInfo} dimensionLabel="Exit page" - afterFetchData={afterFetchData} + onDataReady={onDataReady} /> ) } diff --git a/assets/js/dashboard/stats/pages/index.js b/assets/js/dashboard/stats/pages/index.js index 23682d253874..75d2d07354cc 100644 --- a/assets/js/dashboard/stats/pages/index.js +++ b/assets/js/dashboard/stats/pages/index.js @@ -31,10 +31,10 @@ export default function Pages() { setMode(mode) } - function afterFetchData(apiResponse) { + function onDataReady(queryApiResponse) { setLoading(false) - setSkipImportedReason(apiResponse.skip_imported_reason) - if (apiResponse.results && apiResponse.results.length > 0) { + setSkipImportedReason(queryApiResponse.meta?.imports_skip_reason) + if (queryApiResponse.results && queryApiResponse.results.length > 0) { setMoreLinkState(MoreLinkState.READY) } else { setMoreLinkState(MoreLinkState.HIDDEN) @@ -70,12 +70,12 @@ export default function Pages() { function renderContent() { switch (mode) { case 'entry-pages': - return + return case 'exit-pages': - return + return case 'pages': default: - return + return } } diff --git a/assets/js/dashboard/stats/pages/pages.tsx b/assets/js/dashboard/stats/pages/pages.tsx index d75c1805e9e5..6eefc47943c1 100644 --- a/assets/js/dashboard/stats/pages/pages.tsx +++ b/assets/js/dashboard/stats/pages/pages.tsx @@ -40,9 +40,9 @@ function addSearchFilter(statsQuery: StatsQuery, search: string) { } export function PagesIndex({ - afterFetchData + onDataReady }: { - afterFetchData?: (response: QueryApiResponse) => void + onDataReady?: (data: QueryApiResponse) => void }) { const { dashboardState } = useDashboardStateContext() const site = useSiteContext() @@ -65,7 +65,7 @@ export function PagesIndex({ getExternalLinkUrl={getExternalLinkUrl} getFilterInfo={getFilterInfo} dimensionLabel="Page" - afterFetchData={afterFetchData} + onDataReady={onDataReady} /> ) } diff --git a/assets/js/dashboard/stats/reports/index-breakdown.tsx b/assets/js/dashboard/stats/reports/index-breakdown.tsx index aea3345cf8ec..c069fd9e4c85 100644 --- a/assets/js/dashboard/stats/reports/index-breakdown.tsx +++ b/assets/js/dashboard/stats/reports/index-breakdown.tsx @@ -1,11 +1,11 @@ -import React, { useCallback, useMemo, useState } from 'react' +import React, { useCallback, useMemo, useState, useEffect } from 'react' import FlipMove from 'react-flip-move' import FadeIn from '../../fade-in' import LazyLoader from '../../components/lazy-loader' import { trimURL } from '../../util/url' import { useDashboardStateContext } from '../../dashboard-state-context' import { useSiteContext } from '../../site-context' -import { usePaginatedQueryAPI } from '../../hooks/api-client' +import { getStaleTime } from '../../hooks/api-client' import { createStatsQuery } from '../../stats-query' import type { StatsQuery } from '../../stats-query' import { Metric, getBreakdownMetricLabel } from '../metrics' @@ -19,8 +19,12 @@ import { extractMetricValue } from '../breakdowns' import { DrilldownLink, FilterInfo } from '../../components/drilldown-link' -import * as api from '../../api' -import type { QueryResultRow, QueryResultQuery } from '../../api' +import { + QueryResultRow, + QueryResultQuery, + QueryApiResponse, + stats +} from '../../api' import classNames from 'classnames' import { Tooltip } from '../../util/tooltip' import { ChangeArrow } from './change-arrow' @@ -29,6 +33,7 @@ import { hasConversionGoalFilter, isRealTimeDashboard } from '../../util/filters' +import { useQuery } from '@tanstack/react-query' const MAX_ITEMS = 9 export const MIN_HEIGHT = 356 @@ -44,6 +49,12 @@ const VISITORS_WITH_PERCENTAGE_COLUMN_WIDTH = 'w-32 min-w-32' const BAR_METRIC = 'visitors' +type IndexBreakdownProps = SharedBreakdownReportProps & { + color: string + metricColumnWidth?: string + onDataReady?: (data: QueryApiResponse) => void +} + export function IndexBreakdown({ metrics, dimensions, @@ -51,41 +62,49 @@ export function IndexBreakdown({ getFilterInfo, getExternalLinkUrl, dimensionLabel, - afterFetchData, + onDataReady, metricColumnWidth = DEFAULT_METRIC_COLUMN_WIDTH -}: SharedBreakdownReportProps & { color: string; metricColumnWidth?: string }) { +}: IndexBreakdownProps) { const site = useSiteContext() const { dashboardState } = useDashboardStateContext() const [visible, setVisible] = useState(false) - const [query, setQuery] = useState(null) const statsQuery: StatsQuery = useMemo( () => createStatsQuery(dashboardState, { metrics: metrics, dimensions }), [dashboardState, metrics, dimensions] ) - const handleAfterFetchData = useCallback( - (response: api.QueryApiResponse) => { - setQuery(response.query) - afterFetchData?.(response) - }, - [afterFetchData] - ) - - const apiState = usePaginatedQueryAPI({ - site, - statsQuery, - afterFetchData: handleAfterFetchData, - pageSize: MAX_ITEMS, - enabled: visible + const dimensionKey = statsQuery.dimensions.join(',') + + const apiState = useQuery({ + queryKey: [dimensionKey, dashboardState], + enabled: visible, + queryFn: () => + stats(site, { + ...statsQuery, + pagination: { limit: MAX_ITEMS, offset: 0 } + } as StatsQuery), + staleTime: getStaleTime({ + siteTimezoneOffset: site.offset, + siteStatsBegin: site.statsBegin, + ...dashboardState + }) }) + useEffect(() => { + if (apiState.data && typeof onDataReady === 'function') { + onDataReady(apiState.data) + } + }, [apiState.data, onDataReady]) + + const query: QueryResultQuery | null = apiState.data?.query ?? null + const barMetricIndex = query ? query.metrics.findIndex((m) => m === BAR_METRIC) : null const barMaxValue = useMemo(() => { - const rows = apiState.data?.pages?.[0] ?? [] + const rows = apiState.data?.results ?? [] return barMetricIndex === null ? null : Math.max(...rows.map((r) => r.metrics[barMetricIndex] as number)) @@ -419,14 +438,14 @@ export function IndexBreakdownRenderer({ isPending, columns }: { - data?: { pages: QueryResultRow[][] } + data?: QueryApiResponse isPending: boolean columns: ColumnConfiguration[] | null }) { const [tappedRow, setTappedRow] = useState(null) if (!columns) return null - const rows = data?.pages?.[0]?.slice(0, MAX_ITEMS) ?? [] + const rows = data?.results?.slice(0, MAX_ITEMS) ?? [] if (isPending) { return ( From 05828c296716c1f0b57435975c555afba4217445 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Tue, 28 Apr 2026 23:55:33 +0100 Subject: [PATCH 18/40] fix IndexBreakdownRenderer --- .../stats/reports/index-breakdown.tsx | 169 +++++++++++------- 1 file changed, 104 insertions(+), 65 deletions(-) diff --git a/assets/js/dashboard/stats/reports/index-breakdown.tsx b/assets/js/dashboard/stats/reports/index-breakdown.tsx index c069fd9e4c85..82da147a4686 100644 --- a/assets/js/dashboard/stats/reports/index-breakdown.tsx +++ b/assets/js/dashboard/stats/reports/index-breakdown.tsx @@ -33,7 +33,9 @@ import { hasConversionGoalFilter, isRealTimeDashboard } from '../../util/filters' -import { useQuery } from '@tanstack/react-query' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { DashboardPeriod } from '../../dashboard-time-periods' +import { DashboardState } from '../../dashboard-state' const MAX_ITEMS = 9 export const MIN_HEIGHT = 356 @@ -68,6 +70,9 @@ export function IndexBreakdown({ const site = useSiteContext() const { dashboardState } = useDashboardStateContext() const [visible, setVisible] = useState(false) + const isRealtime = dashboardState.period === DashboardPeriod.realtime + const [isRealtimeSilentUpdate, setIsRealtimeSilentUpdate] = useState(false) + const queryClient = useQueryClient() const statsQuery: StatsQuery = useMemo( () => createStatsQuery(dashboardState, { metrics: metrics, dimensions }), @@ -97,6 +102,38 @@ export function IndexBreakdown({ } }, [apiState.data, onDataReady]) + useEffect(() => { + if (!apiState.isRefetching) { + setIsRealtimeSilentUpdate(false) + } + }, [apiState.isRefetching]) + + useEffect(() => { + if (!isRealtime) { + setIsRealtimeSilentUpdate(false) + } + }, [isRealtime]) + + useEffect(() => { + const onTick = () => { + setIsRealtimeSilentUpdate(true) + queryClient.invalidateQueries({ + predicate: ({ queryKey }) => + queryKey[0] === dimensionKey && + typeof queryKey[1] === 'object' && + (queryKey[1] as DashboardState)?.period === DashboardPeriod.realtime + }) + } + + if (isRealtime) { + document.addEventListener('tick', onTick) + } + + return () => { + document.removeEventListener('tick', onTick) + } + }, [queryClient, isRealtime, dimensionKey]) + const query: QueryResultQuery | null = apiState.data?.query ?? null const barMetricIndex = query @@ -196,7 +233,11 @@ export function IndexBreakdown({ return ( setVisible(true)}> - + ) } @@ -436,18 +477,18 @@ function MetricValueCell({ export function IndexBreakdownRenderer({ data, isPending, + isRealtimeSilentUpdate, columns }: { data?: QueryApiResponse isPending: boolean + isRealtimeSilentUpdate: boolean columns: ColumnConfiguration[] | null }) { const [tappedRow, setTappedRow] = useState(null) - - if (!columns) return null const rows = data?.results?.slice(0, MAX_ITEMS) ?? [] - if (isPending) { + if (!columns || isPending) { return (
-
-
- {columns.map((col) => ( -
- {col.renderLabel()} -
- ))} -
-
- - {rows.map((row) => { - const dimension = row.dimensions[0] - const isActive = tappedRow === dimension - - const handleClick = (e: React.MouseEvent) => { - if ( - window.innerWidth < 768 && - !(e.target as HTMLElement).closest('a') - ) { - setTappedRow(isActive ? null : dimension) - } +
+
+ {columns.map((col) => ( +
+ {col.renderLabel()} +
+ ))} +
+
+ + {rows.map((row) => { + const dimension = row.dimensions[0] + const isActive = tappedRow === dimension + + const handleClick = (e: React.MouseEvent) => { + if ( + window.innerWidth < 768 && + !(e.target as HTMLElement).closest('a') + ) { + setTappedRow(isActive ? null : dimension) } + } - return ( -
-
- {columns.map((col) => ( -
- {col.renderCell(row, isActive)} -
- ))} -
+ return ( +
+
+ {columns.map((col) => ( +
+ {col.renderCell(row, isActive)} +
+ ))}
- ) - })} - -
+
+ ) + })} +
- +
) } From d084c99cd440f3a6befdd72db85d3716d6eae303 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Mon, 4 May 2026 12:30:48 +0300 Subject: [PATCH 19/40] fix NPM tests and lint --- .../stats/graph/fetch-top-stats.test.ts | 50 ++++++++----------- .../stats/reports/index-breakdown.tsx | 1 - 2 files changed, 22 insertions(+), 29 deletions(-) diff --git a/assets/js/dashboard/stats/graph/fetch-top-stats.test.ts b/assets/js/dashboard/stats/graph/fetch-top-stats.test.ts index d259e680e055..17fc81b446a5 100644 --- a/assets/js/dashboard/stats/graph/fetch-top-stats.test.ts +++ b/assets/js/dashboard/stats/graph/fetch-top-stats.test.ts @@ -30,17 +30,23 @@ const expectedBaseInclude: StatsQuery['include'] = { present_index: false } -const expectedRealtimeVisitorsQuery: StatsQuery = { - date_range: DashboardPeriod.realtime, +const expectedBaseQuery = { dimensions: [], filters: [], + include: expectedBaseInclude, + relative_date: null, + order_by: null +} + +const expectedRealtimeVisitorsQuery: StatsQuery = { + ...expectedBaseQuery, + date_range: DashboardPeriod.realtime, include: { ...expectedBaseInclude, compare: null, imports_meta: false }, - metrics: ['visitors'], - relative_date: null + metrics: ['visitors'] } type TestCase = [ @@ -62,12 +68,11 @@ const cases: TestCase[] = [ ['visitors', 'events'], [ { + ...expectedBaseQuery, date_range: DashboardPeriod.realtime_30m, - dimensions: [], filters: remapToApiFilters([aGoalFilter]), include: { ...expectedBaseInclude, compare: null }, - metrics: ['visitors', 'events'], - relative_date: null + metrics: ['visitors', 'events'] }, expectedRealtimeVisitorsQuery ] @@ -79,15 +84,13 @@ const cases: TestCase[] = [ ['visitors', 'pageviews'], [ { + ...expectedBaseQuery, date_range: DashboardPeriod.realtime_30m, - dimensions: [], - filters: [], include: { ...expectedBaseInclude, compare: null }, - metrics: ['visitors', 'pageviews'], - relative_date: null + metrics: ['visitors', 'pageviews'] }, expectedRealtimeVisitorsQuery ] @@ -111,18 +114,16 @@ const cases: TestCase[] = [ ], [ { + ...expectedBaseQuery, date_range: aPeriodNotRealtime, - dimensions: [], filters: remapToApiFilters([aGoalFilter]), - include: expectedBaseInclude, metrics: [ 'visitors', 'events', 'total_revenue', 'average_revenue', 'conversion_rate' - ], - relative_date: null + ] }, null ] @@ -134,12 +135,10 @@ const cases: TestCase[] = [ ['visitors', 'events', 'conversion_rate'], [ { + ...expectedBaseQuery, date_range: aPeriodNotRealtime, - dimensions: [], filters: remapToApiFilters([aGoalFilter]), - include: expectedBaseInclude, - metrics: ['visitors', 'events', 'conversion_rate'], - relative_date: null + metrics: ['visitors', 'events', 'conversion_rate'] }, null ] @@ -162,10 +161,9 @@ const cases: TestCase[] = [ [ { + ...expectedBaseQuery, date_range: aPeriodNotRealtime, - dimensions: [], filters: remapToApiFilters([aPageFilter]), - include: { ...expectedBaseInclude }, metrics: [ 'visitors', 'visits', @@ -173,8 +171,7 @@ const cases: TestCase[] = [ 'bounce_rate', 'scroll_depth', 'time_on_page' - ], - relative_date: null + ] }, null ] @@ -193,10 +190,8 @@ const cases: TestCase[] = [ ], [ { + ...expectedBaseQuery, date_range: aPeriodNotRealtime, - dimensions: [], - filters: [], - include: expectedBaseInclude, metrics: [ 'visitors', 'visits', @@ -204,8 +199,7 @@ const cases: TestCase[] = [ 'views_per_visit', 'bounce_rate', 'visit_duration' - ], - relative_date: null + ] }, null ] diff --git a/assets/js/dashboard/stats/reports/index-breakdown.tsx b/assets/js/dashboard/stats/reports/index-breakdown.tsx index 82da147a4686..c2ecd7a15347 100644 --- a/assets/js/dashboard/stats/reports/index-breakdown.tsx +++ b/assets/js/dashboard/stats/reports/index-breakdown.tsx @@ -1,6 +1,5 @@ import React, { useCallback, useMemo, useState, useEffect } from 'react' import FlipMove from 'react-flip-move' -import FadeIn from '../../fade-in' import LazyLoader from '../../components/lazy-loader' import { trimURL } from '../../util/url' import { useDashboardStateContext } from '../../dashboard-state-context' From 5cac71595edb7e4dc0f32c2a02666b77827fdc39 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Mon, 4 May 2026 12:51:39 +0300 Subject: [PATCH 20/40] mix format --- .../controllers/api/stats_controller/pages_test.exs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/test/plausible_web/controllers/api/stats_controller/pages_test.exs b/test/plausible_web/controllers/api/stats_controller/pages_test.exs index 7120a2f80a41..17aa0c01f082 100644 --- a/test/plausible_web/controllers/api/stats_controller/pages_test.exs +++ b/test/plausible_web/controllers/api/stats_controller/pages_test.exs @@ -2271,7 +2271,10 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do ] end - test "can filter out empty entry pages (sessions with only custom events)", %{conn: conn, site: site} do + test "can filter out empty entry pages (sessions with only custom events)", %{ + conn: conn, + site: site + } do populate_stats(site, [ build(:event, name: "Signup", @@ -2648,7 +2651,10 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do ] end - test "can filter out empty exit pages (sessions with only custom events)", %{conn: conn, site: site} do + test "can filter out empty exit pages (sessions with only custom events)", %{ + conn: conn, + site: site + } do populate_stats(site, [ build(:event, name: "Signup", From 87b3c80b2772dd35f4cc0ac24d3f621713c66a20 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Mon, 4 May 2026 17:02:10 +0300 Subject: [PATCH 21/40] Fix E2E tests - add order by [dim, asc] to Index and DetailsBreakdowns - Define pagination field in StatsQuery and ReportParams --- assets/js/dashboard/hooks/api-client.ts | 2 +- assets/js/dashboard/hooks/use-order-by.ts | 2 +- assets/js/dashboard/stats-query.ts | 11 +++++++--- .../stats/modals/details-breakdown.tsx | 4 +++- .../stats/reports/index-breakdown.tsx | 22 +++++++++++-------- e2e/tests/dashboard/breakdowns.spec.ts | 6 ++--- 6 files changed, 29 insertions(+), 18 deletions(-) diff --git a/assets/js/dashboard/hooks/api-client.ts b/assets/js/dashboard/hooks/api-client.ts index 5086256d3296..25660ad587e8 100644 --- a/assets/js/dashboard/hooks/api-client.ts +++ b/assets/js/dashboard/hooks/api-client.ts @@ -64,7 +64,7 @@ export function usePaginatedQueryAPI({ return api.stats(site, { ...statsQuery, pagination: { limit: PAGINATION_LIMIT, offset: pageParam as number } - } as StatsQuery) + }) }, getNextPageParam: (lastPage, _, lastPageParam) => { return lastPage.results.length === PAGINATION_LIMIT diff --git a/assets/js/dashboard/hooks/use-order-by.ts b/assets/js/dashboard/hooks/use-order-by.ts index a3448e29615d..122d1046fa65 100644 --- a/assets/js/dashboard/hooks/use-order-by.ts +++ b/assets/js/dashboard/hooks/use-order-by.ts @@ -9,7 +9,7 @@ export enum SortDirection { desc = 'desc' } -export type Order = [Metric, SortDirection] +export type Order = [string, SortDirection] export type OrderBy = Order[] diff --git a/assets/js/dashboard/stats-query.ts b/assets/js/dashboard/stats-query.ts index 853f9f4baf33..46107fd013f9 100644 --- a/assets/js/dashboard/stats-query.ts +++ b/assets/js/dashboard/stats-query.ts @@ -5,7 +5,7 @@ import { FilterKey, FilterClause } from './dashboard-state' -import { OrderByEntry } from '../types/query-api' +import { OrderBy } from './hooks/use-order-by' import { ComparisonMode, DashboardPeriod } from './dashboard-time-periods' import { formatISO } from './util/date' import { remapToApiFilters } from './util/filters' @@ -16,6 +16,8 @@ export type ApiFilter = | [FilterOperator, FilterKey, FilterClause[]] | [FilterOperator, FilterKey, FilterClause[], FilterModifiers] +type Pagination = { limit: number; offset: number } + type DateRange = DashboardPeriod | [string, string] type IncludeCompare = | ComparisonMode.previous_period @@ -38,7 +40,8 @@ export type ReportParams = { metrics: Metric[] dimensions?: string[] include?: Partial - order_by?: OrderByEntry[] + order_by?: OrderBy + pagination?: Pagination } export type StatsQuery = { @@ -48,7 +51,8 @@ export type StatsQuery = { dimensions: string[] metrics: Metric[] include: QueryInclude - order_by?: OrderByEntry[] | null + order_by?: OrderBy | null + pagination?: Pagination | null } export function addFilter( @@ -69,6 +73,7 @@ export function createStatsQuery( metrics: reportParams.metrics, filters: remapToApiFilters(dashboardState.filters), order_by: reportParams.order_by || null, + pagination: reportParams.pagination || null, include: { imports: dashboardState.with_imported, imports_meta: reportParams.include?.imports_meta || false, diff --git a/assets/js/dashboard/stats/modals/details-breakdown.tsx b/assets/js/dashboard/stats/modals/details-breakdown.tsx index 58433976d31b..79703ffa0ce3 100644 --- a/assets/js/dashboard/stats/modals/details-breakdown.tsx +++ b/assets/js/dashboard/stats/modals/details-breakdown.tsx @@ -110,7 +110,9 @@ export function DetailsBreakdown({ reportInfo: { dimensionLabel } }) - const effectiveOrderBy = orderBy.length ? orderBy : storedOrderBy + const effectiveOrderBy = (orderBy.length ? orderBy : storedOrderBy).concat( + dimensions.map((dim) => [dim, SortDirection.asc]) + ) const baseStatsQuery: StatsQuery = useMemo( () => diff --git a/assets/js/dashboard/stats/reports/index-breakdown.tsx b/assets/js/dashboard/stats/reports/index-breakdown.tsx index c2ecd7a15347..425d77ba370e 100644 --- a/assets/js/dashboard/stats/reports/index-breakdown.tsx +++ b/assets/js/dashboard/stats/reports/index-breakdown.tsx @@ -35,6 +35,8 @@ import { import { useQuery, useQueryClient } from '@tanstack/react-query' import { DashboardPeriod } from '../../dashboard-time-periods' import { DashboardState } from '../../dashboard-state' +import { SortDirection } from '../../hooks/use-order-by-legacy' +import { OrderBy } from '../../hooks/use-order-by' const MAX_ITEMS = 9 export const MIN_HEIGHT = 356 @@ -73,21 +75,23 @@ export function IndexBreakdown({ const [isRealtimeSilentUpdate, setIsRealtimeSilentUpdate] = useState(false) const queryClient = useQueryClient() - const statsQuery: StatsQuery = useMemo( - () => createStatsQuery(dashboardState, { metrics: metrics, dimensions }), - [dashboardState, metrics, dimensions] - ) + const statsQuery: StatsQuery = useMemo(() => { + return createStatsQuery(dashboardState, { + metrics, + dimensions, + order_by: [['visitors', SortDirection.desc]].concat( + dimensions.map((dim) => [dim, SortDirection.asc]) + ) as OrderBy, + pagination: { limit: MAX_ITEMS, offset: 0 } + }) + }, [dashboardState, metrics, dimensions]) const dimensionKey = statsQuery.dimensions.join(',') const apiState = useQuery({ queryKey: [dimensionKey, dashboardState], enabled: visible, - queryFn: () => - stats(site, { - ...statsQuery, - pagination: { limit: MAX_ITEMS, offset: 0 } - } as StatsQuery), + queryFn: () => stats(site, statsQuery), staleTime: getStaleTime({ siteTimezoneOffset: site.offset, siteStatsBegin: site.statsBegin, diff --git a/e2e/tests/dashboard/breakdowns.spec.ts b/e2e/tests/dashboard/breakdowns.spec.ts index 4e5596d913fa..2f8c4b4f69fa 100644 --- a/e2e/tests/dashboard/breakdowns.spec.ts +++ b/e2e/tests/dashboard/breakdowns.spec.ts @@ -563,7 +563,7 @@ test('pages breakdown', async ({ page, request }) => { await expectHeaders(modal(page), [ 'Entry page', - /Visitors/, + /Unique entrances/, /Total entrances/, /Bounce rate/, /Visit duration/ @@ -599,8 +599,8 @@ test('pages breakdown', async ({ page, request }) => { ).toBeVisible() await expectHeaders(modal(page), [ - 'Page url', - /Visitors/, + 'Exit page', + /Unique exits/, /Total exits/, /Exit rate/ ]) From 9197d232e683e6fdf6cc2a94a7f8bf4a16c8e820 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Mon, 4 May 2026 17:21:37 +0300 Subject: [PATCH 22/40] distribute extra horizontal space evenly In details modals, instead of fixing the dimension cell width and leaving a gap between dimension and metric columns, distribute the empty space evenly between all columns. This is the current prod behaviour in breakdown modals. --- assets/js/dashboard/stats/modals/details-breakdown.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/assets/js/dashboard/stats/modals/details-breakdown.tsx b/assets/js/dashboard/stats/modals/details-breakdown.tsx index 79703ffa0ce3..15f9fa2f9a15 100644 --- a/assets/js/dashboard/stats/modals/details-breakdown.tsx +++ b/assets/js/dashboard/stats/modals/details-breakdown.tsx @@ -180,6 +180,7 @@ export function DetailsBreakdown({ isActive={isActive} /> ), + width: 'w-40 max-w-40 md:w-48 md:max-w-48', align: 'left' }, ...query.metrics @@ -482,7 +483,7 @@ function DimensionCell({ isActive?: boolean }) { return ( -
+
{row.dimensions[0]} From c03265d01d8ec736e952ce57b9f2cc110eed9d85 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Mon, 4 May 2026 17:27:37 +0300 Subject: [PATCH 23/40] fix (NPM) top stats test again --- assets/js/dashboard/stats/graph/fetch-top-stats.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/assets/js/dashboard/stats/graph/fetch-top-stats.test.ts b/assets/js/dashboard/stats/graph/fetch-top-stats.test.ts index 17fc81b446a5..c7cbcf3cbc3d 100644 --- a/assets/js/dashboard/stats/graph/fetch-top-stats.test.ts +++ b/assets/js/dashboard/stats/graph/fetch-top-stats.test.ts @@ -35,7 +35,8 @@ const expectedBaseQuery = { filters: [], include: expectedBaseInclude, relative_date: null, - order_by: null + order_by: null, + pagination: null } const expectedRealtimeVisitorsQuery: StatsQuery = { From f62961819dcfc6aaf27c70c2d27761528f133418 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Mon, 4 May 2026 17:41:37 +0300 Subject: [PATCH 24/40] fix pages_test.exs --- assets/js/dashboard/stats/breakdowns.tsx | 4 ++-- .../controllers/api/stats_controller/pages_test.exs | 13 ++++++++----- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/assets/js/dashboard/stats/breakdowns.tsx b/assets/js/dashboard/stats/breakdowns.tsx index 71856428a4a3..166f21aa6fcc 100644 --- a/assets/js/dashboard/stats/breakdowns.tsx +++ b/assets/js/dashboard/stats/breakdowns.tsx @@ -93,8 +93,8 @@ export function formatDateRangeLabel([from, to]: [string, string]): string { const toDay = dayjs(to.slice(0, 19)) if (fromDay.isSame(toDay, 'day')) return fromDay.format('D MMM YYYY') if (fromDay.isSame(toDay, 'year')) - return `${fromDay.format('D MMM')} – ${toDay.format('D MMM YYYY')}` - return `${fromDay.format('D MMM YY')} – ${toDay.format('D MMM YY')}` + return `${fromDay.format('D MMM')} - ${toDay.format('D MMM YYYY')}` + return `${fromDay.format('D MMM YY')} - ${toDay.format('D MMM YY')}` } export function useBodyPortalRef() { diff --git a/test/plausible_web/controllers/api/stats_controller/pages_test.exs b/test/plausible_web/controllers/api/stats_controller/pages_test.exs index 17aa0c01f082..9b484bd45ae6 100644 --- a/test/plausible_web/controllers/api/stats_controller/pages_test.exs +++ b/test/plausible_web/controllers/api/stats_controller/pages_test.exs @@ -637,7 +637,9 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do %{"dimensions" => ["/firefox"], "metrics" => [2, 100.0]} ] - assert json_response(conn, 200)["meta"] == %{"date_range_label" => "1 Jan 2021"} + assert %{ + "date_range" => ["2021-01-01T00:00:00Z", "2021-01-01T23:59:59Z"] + } = response["query"] end test "returns top pages with :not_member filter on custom pageview props including (none) value", @@ -1236,6 +1238,7 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do query_pages(conn, site, date_range: ["2021-01-01", "2021-01-01"], filters: [["contains", "event:page", ["/blog/(/", "/blog/)/"]]], + order_by: [["visitors", "desc"], ["event:page", "asc"]], metrics: @detailed_metrics ) @@ -1925,10 +1928,10 @@ defmodule PlausibleWeb.Api.StatsController.PagesTest do } ] - assert json_response(conn, 200)["meta"] == %{ - "date_range_label" => "2 Jan 2021", - "comparison_date_range_label" => "1 Jan 2021" - } + assert %{ + "comparison_date_range" => ["2021-01-01T00:00:00Z", "2021-01-01T23:59:59Z"], + "date_range" => ["2021-01-02T00:00:00Z", "2021-01-02T23:59:59Z"] + } = response["query"] end on_ee do From 915b4ba3fe033d1474e6dab8411591773f4df149 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Mon, 4 May 2026 20:24:14 +0300 Subject: [PATCH 25/40] stop rows remaining active after hover due to tooltip --- assets/js/dashboard/util/tooltip.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/js/dashboard/util/tooltip.tsx b/assets/js/dashboard/util/tooltip.tsx index a4a43a21cc03..98febf3dec7d 100644 --- a/assets/js/dashboard/util/tooltip.tsx +++ b/assets/js/dashboard/util/tooltip.tsx @@ -91,7 +91,7 @@ function TooltipMessage({ ref={setPopperElement} style={popperStyle} {...popperAttributes} - className="z-[99] [body:has(.modal.is-open)_&]:z-[1000] px-2 py-1 rounded-sm text-sm text-gray-100 font-medium bg-gray-800 dark:bg-gray-700" + className="pointer-events-none z-[99] [body:has(.modal.is-open)_&]:z-[1000] px-2 py-1 rounded-sm text-sm text-gray-100 font-medium bg-gray-800 dark:bg-gray-700" role="tooltip" > {children} From 1093252510255ee6b02b80ab3e51dc52e3763be6 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Tue, 5 May 2026 16:57:40 +0300 Subject: [PATCH 26/40] remove legacy pages.js --- .../stats/modals/breakdown-modal.test.tsx | 23 ++-- assets/js/dashboard/stats/modals/pages.js | 100 ------------------ 2 files changed, 13 insertions(+), 110 deletions(-) delete mode 100644 assets/js/dashboard/stats/modals/pages.js diff --git a/assets/js/dashboard/stats/modals/breakdown-modal.test.tsx b/assets/js/dashboard/stats/modals/breakdown-modal.test.tsx index 55e56447b2e0..46d50a57f466 100644 --- a/assets/js/dashboard/stats/modals/breakdown-modal.test.tsx +++ b/assets/js/dashboard/stats/modals/breakdown-modal.test.tsx @@ -1,7 +1,7 @@ import React, { useState, Dispatch, SetStateAction } from 'react' import { act, render, screen } from '@testing-library/react' import { TestContextProviders } from '../../../../test-utils/app-context-providers' -import PagesModal from './pages' +import BrowsersModal from './devices/browsers-modal' import { MockAPI } from '../../../../test-utils/mock-api' const domain = 'dummy.site' @@ -37,14 +37,17 @@ describe('BreakdownModal', () => { meta: { date_range_label: 'Last 30 days', metric_warnings: undefined } } - const pagesHandler = mockAPI.get(`/api/stats/${domain}/pages/`, response) + const browsersHandler = mockAPI.get( + `/api/stats/${domain}/browsers/`, + response + ) let setOpen: Dispatch> function ToggleableModal() { const [open, s] = useState(false) setOpen = s - return open ? : null + return open ? : null } render( @@ -53,11 +56,11 @@ describe('BreakdownModal', () => { ) - expect(pagesHandler).toHaveBeenCalledTimes(0) + expect(browsersHandler).toHaveBeenCalledTimes(0) act(() => setOpen(true)) - expect(screen.getByText('Top pages')).toBeVisible() - expect(pagesHandler).toHaveBeenCalledTimes(1) - expect(pagesHandler).toHaveBeenNthCalledWith( + expect(screen.getByText('Browsers')).toBeVisible() + expect(browsersHandler).toHaveBeenCalledTimes(1) + expect(browsersHandler).toHaveBeenNthCalledWith( 1, expect.stringContaining( 'order_by=%5B%5B%22visitors%22%2C%22desc%22%5D%5D&limit=100&page=1' @@ -66,10 +69,10 @@ describe('BreakdownModal', () => { ) act(() => setOpen(false)) - expect(screen.queryByText('Top pages')).not.toBeInTheDocument() + expect(screen.queryByText('Browsers')).not.toBeInTheDocument() act(() => setOpen(true)) - expect(screen.getByText('Top pages')).toBeVisible() + expect(screen.getByText('Browsers')).toBeVisible() - expect(pagesHandler).toHaveBeenCalledTimes(1) + expect(browsersHandler).toHaveBeenCalledTimes(1) }) }) diff --git a/assets/js/dashboard/stats/modals/pages.js b/assets/js/dashboard/stats/modals/pages.js deleted file mode 100644 index f374c6dfe4c3..000000000000 --- a/assets/js/dashboard/stats/modals/pages.js +++ /dev/null @@ -1,100 +0,0 @@ -import React, { useCallback } from 'react' -import Modal from './modal' -import { - hasConversionGoalFilter, - isRealTimeDashboard -} from '../../util/filters' -import { addFilter, revenueAvailable } from '../../dashboard-state' -import BreakdownModal from './breakdown-modal-legacy' -import * as metrics from '../reports/metrics' -import * as url from '../../util/url' -import { useDashboardStateContext } from '../../dashboard-state-context' -import { useSiteContext } from '../../site-context' -import { SortDirection } from '../../hooks/use-order-by-legacy' - -function PagesModal() { - const { dashboardState } = useDashboardStateContext() - const site = useSiteContext() - - /*global BUILD_EXTRA*/ - const showRevenueMetrics = - BUILD_EXTRA && revenueAvailable(dashboardState, site) - - const reportInfo = { - title: 'Top pages', - dimension: 'page', - endpoint: url.apiPath(site, '/pages'), - dimensionLabel: 'Page url', - defaultOrder: ['visitors', SortDirection.desc] - } - - const getFilterInfo = useCallback( - (listItem) => { - return { - prefix: reportInfo.dimension, - filter: ['is', reportInfo.dimension, [listItem.name]] - } - }, - [reportInfo.dimension] - ) - - const addSearchFilter = useCallback( - (dashboardState, searchString) => { - return addFilter(dashboardState, [ - 'contains', - reportInfo.dimension, - [searchString], - { case_sensitive: false } - ]) - }, - [reportInfo.dimension] - ) - - function chooseMetrics() { - if (hasConversionGoalFilter(dashboardState)) { - return [ - metrics.createTotalVisitors(), - metrics.createVisitors({ - renderLabel: (_dashboardState) => 'Conversions', - width: 'w-28' - }), - metrics.createConversionRate(), - showRevenueMetrics && metrics.createTotalRevenue(), - showRevenueMetrics && metrics.createAverageRevenue() - ].filter((metric) => !!metric) - } - - if ( - isRealTimeDashboard(dashboardState) && - !hasConversionGoalFilter(dashboardState) - ) { - return [ - metrics.createVisitors({ - renderLabel: (_dashboardState) => 'Current visitors', - width: 'w-32' - }) - ] - } - - return [ - metrics.createVisitors({ renderLabel: (_dashboardState) => 'Visitors' }), - metrics.createPageviews(), - metrics.createBounceRate(), - metrics.createTimeOnPage(), - metrics.createScrollDepth() - ] - } - - return ( - - - - ) -} - -export default PagesModal From bc9fde139560554616b06763f2c48597af86e21a Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Tue, 5 May 2026 17:05:39 +0300 Subject: [PATCH 27/40] include page index in rowKey --- assets/js/dashboard/components/table.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/assets/js/dashboard/components/table.tsx b/assets/js/dashboard/components/table.tsx index 2fde7a256570..7c2cf54badcf 100644 --- a/assets/js/dashboard/components/table.tsx +++ b/assets/js/dashboard/components/table.tsx @@ -77,9 +77,9 @@ export function Table({
- {data.pages.map((page) => + {data.pages.map((page, pageIndex) => page.map((row) => { - const rowKey = getRowKey(row) + const rowKey = `${getRowKey(row)}_${pageIndex}` return ( Date: Tue, 5 May 2026 18:02:18 +0300 Subject: [PATCH 28/40] test file suggestions --- assets/js/dashboard/stats/metrics.test.ts | 31 +++++++++++++++-------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/assets/js/dashboard/stats/metrics.test.ts b/assets/js/dashboard/stats/metrics.test.ts index 4a17980b87f0..dca5759b0f7f 100644 --- a/assets/js/dashboard/stats/metrics.test.ts +++ b/assets/js/dashboard/stats/metrics.test.ts @@ -1,19 +1,28 @@ -import { isSortable, getMetricLabel, getBreakdownMetricLabel } from './metrics' - -describe('isSortable', () => { +import { + isSortable, + getMetricLabel, + getBreakdownMetricLabel, + Metric +} from './metrics' + +describe(`${isSortable.name}`, () => { it('returns false for total_visitors', () => { expect(isSortable('total_visitors')).toBe(false) }) - it.each(['visitors', 'bounce_rate', 'visit_duration', 'conversion_rate'])( - 'returns true for %s', - (metric) => { - expect(isSortable(metric as Parameters[0])).toBe(true) - } - ) + const sortableMetrics: Metric[] = [ + 'visitors', + 'bounce_rate', + 'visit_duration', + 'conversion_rate' + ] + + it.each(sortableMetrics)('returns true for %s', (metric) => { + expect(isSortable(metric)).toBe(true) + }) }) -describe('getMetricLabel', () => { +describe(`${getMetricLabel.name}`, () => { it.each([ ['visitors', false, 'Unique visitors'], ['visitors', true, 'Unique conversions'], @@ -41,7 +50,7 @@ describe('getMetricLabel', () => { ) }) -describe('getBreakdownMetricLabel', () => { +describe(`${getBreakdownMetricLabel.name}`, () => { const defaults = { hasConversionGoalFilter: false, isRealtime: false } describe('entry page dimension', () => { From 064ac74f1fdd902c74951062d33166abd8f4f17e Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Tue, 5 May 2026 18:20:46 +0300 Subject: [PATCH 29/40] not_sortable -> sortable --- assets/js/dashboard/stats/metrics.test.ts | 13 ++++++++++++- assets/js/dashboard/stats/metrics.ts | 20 ++++++++++++++++++-- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/assets/js/dashboard/stats/metrics.test.ts b/assets/js/dashboard/stats/metrics.test.ts index dca5759b0f7f..ed9f8d48641e 100644 --- a/assets/js/dashboard/stats/metrics.test.ts +++ b/assets/js/dashboard/stats/metrics.test.ts @@ -12,9 +12,20 @@ describe(`${isSortable.name}`, () => { const sortableMetrics: Metric[] = [ 'visitors', + 'visits', + 'pageviews', + 'views_per_visit', 'bounce_rate', 'visit_duration', - 'conversion_rate' + 'events', + 'percentage', + 'conversion_rate', + 'group_conversion_rate', + 'time_on_page', + 'total_revenue', + 'average_revenue', + 'scroll_depth', + 'exit_rate' ] it.each(sortableMetrics)('returns true for %s', (metric) => { diff --git a/assets/js/dashboard/stats/metrics.ts b/assets/js/dashboard/stats/metrics.ts index 98341bda5006..6df7caf94ae4 100644 --- a/assets/js/dashboard/stats/metrics.ts +++ b/assets/js/dashboard/stats/metrics.ts @@ -2,10 +2,26 @@ import { Metric as PublicApiMetric } from '../../types/query-api' export type Metric = PublicApiMetric | 'total_visitors' | 'exit_rate' -const NOT_SORTABLE = ['total_visitors'] +const SORTABLE = [ + 'visitors', + 'visits', + 'pageviews', + 'views_per_visit', + 'bounce_rate', + 'visit_duration', + 'events', + 'percentage', + 'conversion_rate', + 'group_conversion_rate', + 'time_on_page', + 'total_revenue', + 'average_revenue', + 'scroll_depth', + 'exit_rate' +] export const isSortable = (metric: Metric): boolean => { - return !NOT_SORTABLE.includes(metric) + return SORTABLE.includes(metric) } export const getMetricLabel = ( From d19018ef0e0d45164bf88052c3518dfa945a2138 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Wed, 6 May 2026 10:05:26 +0300 Subject: [PATCH 30/40] give more width to dimension cells --- assets/js/dashboard/stats/modals/details-breakdown.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/js/dashboard/stats/modals/details-breakdown.tsx b/assets/js/dashboard/stats/modals/details-breakdown.tsx index 15f9fa2f9a15..ec315666271c 100644 --- a/assets/js/dashboard/stats/modals/details-breakdown.tsx +++ b/assets/js/dashboard/stats/modals/details-breakdown.tsx @@ -180,7 +180,7 @@ export function DetailsBreakdown({ isActive={isActive} /> ), - width: 'w-40 max-w-40 md:w-48 md:max-w-48', + width: 'w-48 max-w-48 md:w-56 md:max-w-56', align: 'left' }, ...query.metrics From b948931f7b3cf33b61a397a1b3084391102cec64 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Wed, 6 May 2026 10:09:35 +0300 Subject: [PATCH 31/40] fix legacy breakdown table min-height --- assets/js/dashboard/stats/modals/breakdown-table-legacy.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/assets/js/dashboard/stats/modals/breakdown-table-legacy.tsx b/assets/js/dashboard/stats/modals/breakdown-table-legacy.tsx index 0944f3afb203..18ca9cde25a4 100644 --- a/assets/js/dashboard/stats/modals/breakdown-table-legacy.tsx +++ b/assets/js/dashboard/stats/modals/breakdown-table-legacy.tsx @@ -44,7 +44,7 @@ export const BreakdownTable = ({ onClose ?? (() => navigate({ path: rootRoute.path, search: (s) => s })) return ( - <> +

@@ -84,7 +84,7 @@ export const BreakdownTable = ({ /> )}

- +
) } From e2819727a90f556b937975a74913b0de21a83c12 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Wed, 6 May 2026 10:20:28 +0300 Subject: [PATCH 32/40] make metric label test match its description --- assets/js/dashboard/stats/metrics.test.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/assets/js/dashboard/stats/metrics.test.ts b/assets/js/dashboard/stats/metrics.test.ts index ed9f8d48641e..a28120fc1ad0 100644 --- a/assets/js/dashboard/stats/metrics.test.ts +++ b/assets/js/dashboard/stats/metrics.test.ts @@ -247,10 +247,15 @@ describe(`${getBreakdownMetricLabel.name}`, () => { ) }) - it('delegates to getMetricLabel for other metrics', () => { - expect( - getBreakdownMetricLabel('bounce_rate', { ...defaults, dimensions }) - ).toBe('Bounce rate') + it.each([ + 'visits', + 'views_per_visit', + 'bounce_rate', + 'visit_duration' + ] as const)('delegates to getMetricLabel for %s', (metric) => { + expect(getBreakdownMetricLabel(metric, { ...defaults, dimensions })).toBe( + getMetricLabel(metric, defaults) + ) }) }) }) From 7716c108628f965fbdae3c4cd5c4261ec1975808 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Wed, 6 May 2026 10:29:33 +0300 Subject: [PATCH 33/40] use QueryResultQuery type in MainGraphResponse type --- assets/js/dashboard/api.ts | 1 + assets/js/dashboard/stats/graph/fetch-main-graph.ts | 8 +------- assets/js/dashboard/stats/graph/fetch-top-stats.test.ts | 1 + 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/assets/js/dashboard/api.ts b/assets/js/dashboard/api.ts index a77f88ecfc10..91e76fab6627 100644 --- a/assets/js/dashboard/api.ts +++ b/assets/js/dashboard/api.ts @@ -20,6 +20,7 @@ export type MetricValue = null | number | RevenueMetricValue export type QueryResultQuery = { metrics: Metric[] + dimensions: string[] date_range: [string, string] comparison_date_range?: [string, string] | null } diff --git a/assets/js/dashboard/stats/graph/fetch-main-graph.ts b/assets/js/dashboard/stats/graph/fetch-main-graph.ts index be2ac6883457..ff167c74f733 100644 --- a/assets/js/dashboard/stats/graph/fetch-main-graph.ts +++ b/assets/js/dashboard/stats/graph/fetch-main-graph.ts @@ -58,11 +58,5 @@ export type MainGraphResponse = { empty_metrics: MetricValues present_index: number } - query: { - interval: string - date_range: [string, string] - comparison_date_range?: [string, string] - dimensions: [string] // one item - metrics: [string] // one item - } + query: api.QueryResultQuery } diff --git a/assets/js/dashboard/stats/graph/fetch-top-stats.test.ts b/assets/js/dashboard/stats/graph/fetch-top-stats.test.ts index c7cbcf3cbc3d..9d12a4663ffb 100644 --- a/assets/js/dashboard/stats/graph/fetch-top-stats.test.ts +++ b/assets/js/dashboard/stats/graph/fetch-top-stats.test.ts @@ -269,6 +269,7 @@ function makeTopStatsResponse( return { query: { metrics: ['visitors'] as ['visitors'], + dimensions: [], date_range: dateRange, comparison_date_range: comparisonDateRange as [string, string] }, From 04e8502d03f6d896d3e65836081685a8b36a7e4f Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Wed, 6 May 2026 10:37:06 +0300 Subject: [PATCH 34/40] dimensionLabel argument to useOrderBy (v2) instead of reportInfo --- .../js/dashboard/hooks/use-order-by.test.ts | 20 ++++++------ assets/js/dashboard/hooks/use-order-by.ts | 31 +++++++++---------- .../stats/modals/details-breakdown.tsx | 4 +-- 3 files changed, 27 insertions(+), 28 deletions(-) diff --git a/assets/js/dashboard/hooks/use-order-by.test.ts b/assets/js/dashboard/hooks/use-order-by.test.ts index f555abe058b6..1f0787c62627 100644 --- a/assets/js/dashboard/hooks/use-order-by.test.ts +++ b/assets/js/dashboard/hooks/use-order-by.test.ts @@ -110,35 +110,35 @@ describe(`${validateOrderBy.name}`, () => { describe(`storing detailed report preferred order`, () => { const domain = 'any-domain' - const reportInfo = { dimensionLabel: 'Goal' } + const dimensionLabel = 'Goal' it('does not store invalid value', () => { maybeStoreOrderBy({ orderBy: [['total_visitors', SortDirection.desc]], domain, - reportInfo, + dimensionLabel, metrics: ['total_visitors'] }) - expect(localStorage.getItem(getOrderByStorageKey(domain, reportInfo))).toBe( - null - ) + expect( + localStorage.getItem(getOrderByStorageKey(domain, dimensionLabel)) + ).toBe(null) }) it('falls back to fallbackValue if metric has become unsortable between storing and retrieving', () => { maybeStoreOrderBy({ orderBy: [['visitors', SortDirection.desc]], domain, - reportInfo, + dimensionLabel, metrics: ['visitors'] }) const inStorage = localStorage.getItem( - getOrderByStorageKey(domain, reportInfo) + getOrderByStorageKey(domain, dimensionLabel) ) expect(inStorage).toBe('[["visitors","desc"]]') expect( getStoredOrderBy({ domain, - reportInfo, + dimensionLabel, metrics: ['total_visitors'], fallbackValue: [['visitors', SortDirection.desc]] }) @@ -148,13 +148,13 @@ describe(`storing detailed report preferred order`, () => { it('retrieves stored value correctly', () => { const input: OrderBy = [['visitors', SortDirection.asc]] localStorage.setItem( - getOrderByStorageKey(domain, reportInfo), + getOrderByStorageKey(domain, dimensionLabel), JSON.stringify(input) ) expect( getStoredOrderBy({ domain, - reportInfo, + dimensionLabel, metrics: ['visitors'], fallbackValue: [['visitors', SortDirection.desc]] }) diff --git a/assets/js/dashboard/hooks/use-order-by.ts b/assets/js/dashboard/hooks/use-order-by.ts index 122d1046fa65..0a7260990675 100644 --- a/assets/js/dashboard/hooks/use-order-by.ts +++ b/assets/js/dashboard/hooks/use-order-by.ts @@ -2,7 +2,6 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { isSortable, Metric } from '../stats/metrics' import { getDomainScopedStorageKey, getItem, setItem } from '../util/storage' import { useSiteContext } from '../site-context' -import { ReportInfo } from '../stats/modals/breakdown-modal-legacy' export enum SortDirection { asc = 'asc', @@ -94,12 +93,9 @@ export function rearrangeOrderBy( return [[metric, sortDirection]] } -export function getOrderByStorageKey( - domain: string, - reportInfo: Pick -) { +export function getOrderByStorageKey(domain: string, dimensionLabel: string) { const storageKey = getDomainScopedStorageKey( - `order_${reportInfo.dimensionLabel}_by`, + `order_${dimensionLabel}_by`, domain ) return storageKey @@ -130,17 +126,17 @@ export function validateOrderBy( export function getStoredOrderBy({ domain, - reportInfo, + dimensionLabel, metrics, fallbackValue }: { domain: string - reportInfo: Pick + dimensionLabel: string metrics: Metric[] fallbackValue: OrderBy }): OrderBy { try { - const storedItem = getItem(getOrderByStorageKey(domain, reportInfo)) + const storedItem = getItem(getOrderByStorageKey(domain, dimensionLabel)) const parsed = JSON.parse(storedItem) if ( validateOrderBy( @@ -159,12 +155,12 @@ export function getStoredOrderBy({ export function maybeStoreOrderBy({ domain, - reportInfo, + dimensionLabel, metrics, orderBy }: { domain: string - reportInfo: Pick + dimensionLabel: string metrics: Metric[] orderBy: OrderBy }) { @@ -174,18 +170,21 @@ export function maybeStoreOrderBy({ metrics.filter((m) => isSortable(m)) ) ) { - setItem(getOrderByStorageKey(domain, reportInfo), JSON.stringify(orderBy)) + setItem( + getOrderByStorageKey(domain, dimensionLabel), + JSON.stringify(orderBy) + ) } } export function useRememberOrderBy({ effectiveOrderBy, metrics, - reportInfo + dimensionLabel }: { effectiveOrderBy: OrderBy metrics: Metric[] - reportInfo: Pick + dimensionLabel: string }) { const site = useSiteContext() @@ -193,8 +192,8 @@ export function useRememberOrderBy({ maybeStoreOrderBy({ domain: site.domain, metrics, - reportInfo, + dimensionLabel, orderBy: effectiveOrderBy }) - }, [site, reportInfo, effectiveOrderBy, metrics]) + }, [site, dimensionLabel, effectiveOrderBy, metrics]) } diff --git a/assets/js/dashboard/stats/modals/details-breakdown.tsx b/assets/js/dashboard/stats/modals/details-breakdown.tsx index ec315666271c..47b7b26480bf 100644 --- a/assets/js/dashboard/stats/modals/details-breakdown.tsx +++ b/assets/js/dashboard/stats/modals/details-breakdown.tsx @@ -94,7 +94,7 @@ export function DetailsBreakdown({ const storedOrderBy = getStoredOrderBy({ domain: site.domain, - reportInfo: { dimensionLabel }, + dimensionLabel, metrics, fallbackValue: defaultOrderBy }) @@ -107,7 +107,7 @@ export function DetailsBreakdown({ useRememberOrderBy({ effectiveOrderBy: orderBy, metrics, - reportInfo: { dimensionLabel } + dimensionLabel }) const effectiveOrderBy = (orderBy.length ? orderBy : storedOrderBy).concat( From 1319a2e1f9c604b8fce701ae07a43c167d2139a5 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Wed, 6 May 2026 10:40:39 +0300 Subject: [PATCH 35/40] remove unnecessary wrapper fn --- assets/js/dashboard/hooks/use-order-by.test.ts | 18 ------------------ assets/js/dashboard/hooks/use-order-by.ts | 6 +----- 2 files changed, 1 insertion(+), 23 deletions(-) diff --git a/assets/js/dashboard/hooks/use-order-by.test.ts b/assets/js/dashboard/hooks/use-order-by.test.ts index 1f0787c62627..044003756738 100644 --- a/assets/js/dashboard/hooks/use-order-by.test.ts +++ b/assets/js/dashboard/hooks/use-order-by.test.ts @@ -3,7 +3,6 @@ import { OrderBy, SortDirection, cycleSortDirection, - findOrderIndex, getOrderByStorageKey, getStoredOrderBy, maybeStoreOrderBy, @@ -11,23 +10,6 @@ import { validateOrderBy } from './use-order-by' -describe(`${findOrderIndex.name}`, () => { - /* prettier-ignore */ - const cases: [OrderBy, Metric, number][] = [ - [[], 'visitors', -1], - [[['visitors', SortDirection.asc]], 'bounce_rate', -1], - [[['bounce_rate', SortDirection.desc], ['visitors', SortDirection.asc]], 'bounce_rate', 0], - [[['bounce_rate', SortDirection.desc], ['visitors', SortDirection.asc]], 'visitors', 1] - ] - - test.each(cases)( - `[%#] in order by %p, the index of metric %p is %p`, - (orderBy, metric, expectedIndex) => { - expect(findOrderIndex(orderBy, metric)).toEqual(expectedIndex) - } - ) -}) - describe(`${cycleSortDirection.name}`, () => { test.each([ [ diff --git a/assets/js/dashboard/hooks/use-order-by.ts b/assets/js/dashboard/hooks/use-order-by.ts index 0a7260990675..48358bda64f8 100644 --- a/assets/js/dashboard/hooks/use-order-by.ts +++ b/assets/js/dashboard/hooks/use-order-by.ts @@ -72,15 +72,11 @@ export function cycleSortDirection( } } -export function findOrderIndex(orderBy: OrderBy, metric: Metric) { - return orderBy.findIndex(([m]) => m === metric) -} - export function rearrangeOrderBy( currentOrderBy: OrderBy, metric: Metric ): OrderBy { - const orderIndex = findOrderIndex(currentOrderBy, metric) + const orderIndex = currentOrderBy.findIndex(([m]) => m === metric) if (orderIndex < 0) { const sortDirection = cycleSortDirection(null).direction as SortDirection return [[metric, sortDirection]] From 2f64b3a7e95c948efbeab36bb4a1a82fe2f576e6 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Wed, 6 May 2026 10:58:59 +0300 Subject: [PATCH 36/40] remove unnecessary cast --- assets/js/dashboard/hooks/use-order-by.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/js/dashboard/hooks/use-order-by.ts b/assets/js/dashboard/hooks/use-order-by.ts index 48358bda64f8..215b1a6837ef 100644 --- a/assets/js/dashboard/hooks/use-order-by.ts +++ b/assets/js/dashboard/hooks/use-order-by.ts @@ -30,7 +30,7 @@ export function useOrderBy({ () => (orderBy.length ? Object.fromEntries(orderBy) - : Object.fromEntries(defaultOrderBy)) as Record, + : Object.fromEntries(defaultOrderBy)), [orderBy, defaultOrderBy] ) From fb6a759a7b6b03df257d61013ca1098569a498e0 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Wed, 6 May 2026 11:09:11 +0300 Subject: [PATCH 37/40] remove unnecessary anonymous function --- assets/js/dashboard/hooks/use-order-by.ts | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/assets/js/dashboard/hooks/use-order-by.ts b/assets/js/dashboard/hooks/use-order-by.ts index 215b1a6837ef..01b5de2a2366 100644 --- a/assets/js/dashboard/hooks/use-order-by.ts +++ b/assets/js/dashboard/hooks/use-order-by.ts @@ -28,9 +28,9 @@ export function useOrderBy({ const [orderBy, setOrderBy] = useState([]) const orderByDictionary = useMemo( () => - (orderBy.length + orderBy.length ? Object.fromEntries(orderBy) - : Object.fromEntries(defaultOrderBy)), + : Object.fromEntries(defaultOrderBy), [orderBy, defaultOrderBy] ) @@ -134,12 +134,7 @@ export function getStoredOrderBy({ try { const storedItem = getItem(getOrderByStorageKey(domain, dimensionLabel)) const parsed = JSON.parse(storedItem) - if ( - validateOrderBy( - parsed, - metrics.filter((m) => isSortable(m)) - ) - ) { + if (validateOrderBy(parsed, metrics.filter(isSortable))) { return parsed } else { throw new Error('Invalid stored order_by value') From 8493e9bd3c4814162c947bd217ccac7001323315 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Wed, 6 May 2026 11:21:32 +0300 Subject: [PATCH 38/40] bring back pages_test as legacy (still used by CSV export) --- .../stats_controller/pages_legacy_test.exs | 3525 +++++++++++++++++ 1 file changed, 3525 insertions(+) create mode 100644 test/plausible_web/controllers/api/stats_controller/pages_legacy_test.exs diff --git a/test/plausible_web/controllers/api/stats_controller/pages_legacy_test.exs b/test/plausible_web/controllers/api/stats_controller/pages_legacy_test.exs new file mode 100644 index 000000000000..292813bfbe61 --- /dev/null +++ b/test/plausible_web/controllers/api/stats_controller/pages_legacy_test.exs @@ -0,0 +1,3525 @@ +defmodule PlausibleWeb.Api.StatsController.PagesLegacyTest do + @moduledoc """ + [DEPRECATED] Tests for the pages, entry_pages, and exit_pages + actions in StatsController which are no longer used by the + dashboard pages reports. Still used by Dashboard CSV export + though, which is why we're keeping it around until the CSV + export is migrated to v2 API. + """ + use PlausibleWeb.ConnCase + + @user_id Enum.random(1000..9999) + + describe "GET /api/stats/:domain/pages" do + setup [ + :create_user, + :log_in, + :create_site, + :create_legacy_site_import + ] + + test "returns top pages by visitors", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, pathname: "/"), + build(:pageview, pathname: "/"), + build(:pageview, pathname: "/"), + build(:pageview, pathname: "/register"), + build(:pageview, pathname: "/register"), + build(:pageview, pathname: "/contact") + ]) + + conn = get(conn, "/api/stats/#{site.domain}/pages?period=day") + + assert json_response(conn, 200)["results"] == [ + %{"visitors" => 3, "name" => "/", "percentage" => 50.0}, + %{"visitors" => 2, "name" => "/register", "percentage" => 33.33}, + %{"visitors" => 1, "name" => "/contact", "percentage" => 16.67} + ] + end + + test "returns top pages by visitors by hostname", %{conn: conn1, site: site} do + populate_stats(site, [ + build(:pageview, pathname: "/", hostname: "a.example.com"), + build(:pageview, pathname: "/", hostname: "b.example.com"), + build(:pageview, pathname: "/", hostname: "d.example.com"), + build(:pageview, pathname: "/landing", hostname: "x.example.com", user_id: 123), + build(:pageview, pathname: "/register", hostname: "d.example.com", user_id: 123), + build(:pageview, pathname: "/register", hostname: "d.example.com", user_id: 123), + build(:pageview, pathname: "/register", hostname: "d.example.com"), + build(:pageview, pathname: "/contact", hostname: "e.example.com") + ]) + + filters = Jason.encode!([[:contains, "event:hostname", [".example.com"]]]) + conn = get(conn1, "/api/stats/#{site.domain}/pages?period=day&filters=#{filters}") + + assert json_response(conn, 200)["results"] == [ + %{"visitors" => 3, "name" => "/", "percentage" => 50.0}, + %{"visitors" => 2, "name" => "/register", "percentage" => 33.33}, + %{"visitors" => 1, "name" => "/contact", "percentage" => 16.67}, + %{"visitors" => 1, "name" => "/landing", "percentage" => 16.67} + ] + + filters = Jason.encode!([[:is, "event:hostname", ["d.example.com"]]]) + conn = get(conn1, "/api/stats/#{site.domain}/pages?period=day&filters=#{filters}") + + assert json_response(conn, 200)["results"] == [ + %{"visitors" => 2, "name" => "/register", "percentage" => 66.67}, + %{"visitors" => 1, "name" => "/", "percentage" => 33.33} + ] + end + + test "returns top pages with :is filter on custom pageview props", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + pathname: "/blog/john-1", + "meta.key": ["author"], + "meta.value": ["John Doe"] + ), + build(:pageview, + pathname: "/blog/other-post", + "meta.key": ["author"], + "meta.value": ["other"] + ), + build(:pageview, user_id: 123, pathname: "/") + ]) + + filters = Jason.encode!([[:is, "event:props:author", ["John Doe"]]]) + conn = get(conn, "/api/stats/#{site.domain}/pages?period=day&filters=#{filters}") + + assert json_response(conn, 200)["results"] == [ + %{"visitors" => 1, "name" => "/blog/john-1", "percentage" => 100.0} + ] + end + + test "returns top pages with :is_not filter on custom pageview props", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, + pathname: "/blog/john-1", + "meta.key": ["author"], + "meta.value": ["John Doe"] + ), + build(:pageview, + pathname: "/blog/other-post", + "meta.key": ["author"], + "meta.value": ["other"] + ), + build(:pageview, pathname: "/") + ]) + + filters = Jason.encode!([[:is_not, "event:props:author", ["John Doe"]]]) + conn = get(conn, "/api/stats/#{site.domain}/pages?period=day&filters=#{filters}") + + assert json_response(conn, 200)["results"] == [ + %{"visitors" => 1, "name" => "/", "percentage" => 50.0}, + %{"visitors" => 1, "name" => "/blog/other-post", "percentage" => 50.0} + ] + end + + test "returns top pages with :matches_wildcard filter on custom pageview props", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, + pathname: "/1", + "meta.key": ["prop"], + "meta.value": ["bar"] + ), + build(:pageview, + pathname: "/2", + "meta.key": ["prop"], + "meta.value": ["foobar"] + ), + build(:pageview, + pathname: "/3", + "meta.key": ["prop"], + "meta.value": ["baar"] + ), + build(:pageview, + pathname: "/4", + "meta.key": ["another"], + "meta.value": ["bar"] + ), + build(:pageview, pathname: "/5") + ]) + + filters = Jason.encode!([[:contains, "event:props:prop", ["bar"]]]) + conn = get(conn, "/api/stats/#{site.domain}/pages?period=day&filters=#{filters}") + + assert json_response(conn, 200)["results"] == [ + %{"visitors" => 1, "name" => "/1", "percentage" => 50.0}, + %{"visitors" => 1, "name" => "/2", "percentage" => 50.0} + ] + end + + test "returns top pages with :matches_member filter on custom pageview props", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, + pathname: "/1", + "meta.key": ["prop"], + "meta.value": ["bar"] + ), + build(:pageview, + pathname: "/2", + "meta.key": ["prop"], + "meta.value": ["foobar"] + ), + build(:pageview, + pathname: "/3", + "meta.key": ["prop"], + "meta.value": ["baar"] + ), + build(:pageview, + pathname: "/4", + "meta.key": ["another"], + "meta.value": ["bar"] + ), + build(:pageview, pathname: "/5"), + build(:pageview, + pathname: "/6", + "meta.key": ["prop"], + "meta.value": ["near"] + ) + ]) + + filters = Jason.encode!([[:contains, "event:props:prop", ["bar", "nea"]]]) + conn = get(conn, "/api/stats/#{site.domain}/pages?period=day&filters=#{filters}") + + assert json_response(conn, 200)["results"] == [ + %{"visitors" => 1, "name" => "/1", "percentage" => 33.33}, + %{"visitors" => 1, "name" => "/2", "percentage" => 33.33}, + %{"visitors" => 1, "name" => "/6", "percentage" => 33.33} + ] + end + + test "returns top pages with multiple filters on custom pageview props", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, + pathname: "/1", + "meta.key": ["prop", "number"], + "meta.value": ["bar", "1"] + ), + build(:pageview, + pathname: "/2", + "meta.key": ["prop", "number"], + "meta.value": ["bar", "2"] + ), + build(:pageview, + pathname: "/3", + "meta.key": ["prop"], + "meta.value": ["bar"] + ), + build(:pageview, + pathname: "/4", + "meta.key": ["number"], + "meta.value": ["bar"] + ), + build(:pageview, pathname: "/5") + ]) + + filters = + Jason.encode!([ + [:is, "event:props:prop", ["bar"]], + [:is, "event:props:number", ["1"]] + ]) + + conn = get(conn, "/api/stats/#{site.domain}/pages?period=day&filters=#{filters}") + + assert json_response(conn, 200)["results"] == [ + %{"visitors" => 1, "name" => "/1", "percentage" => 100.0} + ] + end + + test "calculates bounce_rate and time_on_page with :is filter on custom pageview props", + %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + pathname: "/blog/john-1", + "meta.key": ["author"], + "meta.value": ["John Doe"], + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:engagement, + pathname: "/blog/john-1", + "meta.key": ["author"], + "meta.value": ["John Doe"], + user_id: @user_id, + timestamp: ~N[2021-01-01 00:01:00], + engagement_time: 60_000 + ), + build(:pageview, + pathname: "/blog", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:01:00] + ), + build(:engagement, + pathname: "/blog", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:02:00], + engagement_time: 60_000 + ), + build(:pageview, + pathname: "/blog/john-2", + "meta.key": ["author"], + "meta.value": ["John Doe"], + user_id: @user_id, + timestamp: ~N[2021-01-01 00:02:00] + ), + build(:engagement, + pathname: "/blog/john-2", + "meta.key": ["author"], + "meta.value": ["John Doe"], + user_id: @user_id, + timestamp: ~N[2021-01-01 00:02:30], + engagement_time: 30_000 + ), + build(:pageview, + pathname: "/blog/john-2", + "meta.key": ["author"], + "meta.value": ["John Doe"], + user_id: 456, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:engagement, + pathname: "/blog/john-2", + "meta.key": ["author"], + "meta.value": ["John Doe"], + user_id: 456, + timestamp: ~N[2021-01-01 00:10:00], + engagement_time: 600_000 + ), + build(:pageview, + pathname: "/blog", + user_id: 456, + timestamp: ~N[2021-01-01 00:10:00] + ), + build(:engagement, + pathname: "/blog", + user_id: 456, + timestamp: ~N[2021-01-01 00:10:30], + engagement_time: 30_000 + ) + ]) + + filters = Jason.encode!([[:is, "event:props:author", ["John Doe"]]]) + + conn = + get( + conn, + "/api/stats/#{site.domain}/pages?period=day&date=2021-01-01&filters=#{filters}&detailed=true" + ) + + assert json_response(conn, 200)["results"] == [ + %{ + "name" => "/blog/john-2", + "visitors" => 2, + "pageviews" => 2, + "bounce_rate" => 0, + "time_on_page" => 315, + "scroll_depth" => 0, + "percentage" => 100.0 + }, + %{ + "name" => "/blog/john-1", + "visitors" => 1, + "pageviews" => 1, + "bounce_rate" => 0, + "time_on_page" => 60, + "scroll_depth" => 0, + "percentage" => 50.0 + } + ] + end + + test "calculates bounce_rate and time_on_page with :is_not filter on custom pageview props", + %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + pathname: "/blog", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:engagement, + pathname: "/blog", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:01:00], + engagement_time: 60_000 + ), + build(:pageview, + pathname: "/blog/john-1", + user_id: @user_id, + "meta.key": ["author"], + "meta.value": ["John Doe"], + timestamp: ~N[2021-01-01 00:01:00] + ), + build(:engagement, + pathname: "/blog/john-1", + user_id: @user_id, + "meta.key": ["author"], + "meta.value": ["John Doe"], + timestamp: ~N[2021-01-01 00:02:00], + engagement_time: 60_000 + ), + build(:pageview, + pathname: "/blog/other-post", + user_id: @user_id, + "meta.key": ["author"], + "meta.value": ["other"], + timestamp: ~N[2021-01-01 00:02:00] + ), + build(:engagement, + pathname: "/blog/other-post", + user_id: @user_id, + "meta.key": ["author"], + "meta.value": ["other"], + timestamp: ~N[2021-01-01 00:02:30], + engagement_time: 30_000 + ), + build(:pageview, + pathname: "/blog", + user_id: 456, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:engagement, + pathname: "/blog", + user_id: 456, + timestamp: ~N[2021-01-01 00:03:00], + engagement_time: 180_000 + ), + build(:pageview, + pathname: "/blog/john-1", + "meta.key": ["author"], + "meta.value": ["John Doe"], + user_id: 456, + timestamp: ~N[2021-01-01 00:03:00] + ), + build(:engagement, + pathname: "/blog/john-1", + "meta.key": ["author"], + "meta.value": ["John Doe"], + user_id: 456, + timestamp: ~N[2021-01-01 00:03:30], + engagement_time: 30_000 + ) + ]) + + filters = Jason.encode!([[:is_not, "event:props:author", ["John Doe"]]]) + + conn = + get( + conn, + "/api/stats/#{site.domain}/pages?period=day&date=2021-01-01&filters=#{filters}&detailed=true" + ) + + assert json_response(conn, 200)["results"] == [ + %{ + "name" => "/blog", + "visitors" => 2, + "pageviews" => 2, + "bounce_rate" => 0, + "time_on_page" => 120, + "scroll_depth" => 0, + "percentage" => 100.0 + }, + %{ + "name" => "/blog/other-post", + "visitors" => 1, + "pageviews" => 1, + "bounce_rate" => 0, + "time_on_page" => 30, + "scroll_depth" => 0, + "percentage" => 50.0 + } + ] + end + + test "calculates bounce_rate and time_on_page with :is (none) filter on custom pageview props", + %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + pathname: "/blog", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:engagement, + pathname: "/blog", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:01:00], + engagement_time: 60_000 + ), + build(:pageview, + pathname: "/blog/john-1", + user_id: @user_id, + "meta.key": ["author"], + "meta.value": ["John Doe"], + timestamp: ~N[2021-01-01 00:01:00] + ), + build(:engagement, + pathname: "/blog/john-1", + user_id: @user_id, + "meta.key": ["author"], + "meta.value": ["John Doe"], + timestamp: ~N[2021-01-01 00:02:00], + engagement_time: 60_000 + ), + build(:pageview, + pathname: "/blog/other-post", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:02:00] + ), + build(:engagement, + pathname: "/blog/other-post", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:02:30], + engagement_time: 30_000 + ), + build(:pageview, + pathname: "/blog", + user_id: 456, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:engagement, + pathname: "/blog", + user_id: 456, + timestamp: ~N[2021-01-01 00:00:30], + engagement_time: 30_000 + ) + ]) + + filters = Jason.encode!([[:is, "event:props:author", ["(none)"]]]) + + conn = + get( + conn, + "/api/stats/#{site.domain}/pages?period=day&date=2021-01-01&filters=#{filters}&detailed=true" + ) + + assert json_response(conn, 200)["results"] == [ + %{ + "name" => "/blog", + "visitors" => 2, + "pageviews" => 2, + "bounce_rate" => 50, + "time_on_page" => 45, + "scroll_depth" => 0, + "percentage" => 100.0 + }, + %{ + "name" => "/blog/other-post", + "visitors" => 1, + "pageviews" => 1, + "bounce_rate" => 0, + "time_on_page" => 30, + "scroll_depth" => 0, + "percentage" => 50.0 + } + ] + end + + test "calculates bounce_rate and time_on_page with :is_not (none) filter on custom pageview props", + %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + pathname: "/blog/john-1", + user_id: @user_id, + "meta.key": ["author"], + "meta.value": ["John Doe"], + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:engagement, + pathname: "/blog/john-1", + user_id: @user_id, + "meta.key": ["author"], + "meta.value": ["John Doe"], + timestamp: ~N[2021-01-01 00:01:00], + engagement_time: 60_000 + ), + build(:pageview, + pathname: "/blog", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:01:00] + ), + build(:engagement, + pathname: "/blog", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:02:00], + engagement_time: 60_000 + ), + build(:pageview, + pathname: "/blog/other-post", + "meta.key": ["author"], + "meta.value": ["other"], + user_id: @user_id, + timestamp: ~N[2021-01-01 00:02:00] + ), + build(:engagement, + pathname: "/blog/other-post", + "meta.key": ["author"], + "meta.value": ["other"], + user_id: @user_id, + timestamp: ~N[2021-01-01 00:02:30], + engagement_time: 30_000 + ), + build(:pageview, + pathname: "/blog/other-post", + "meta.key": ["author"], + "meta.value": [""], + user_id: 456, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:engagement, + pathname: "/blog/other-post", + "meta.key": ["author"], + "meta.value": [""], + user_id: 456, + timestamp: ~N[2021-01-01 00:00:30], + engagement_time: 30_000 + ) + ]) + + filters = Jason.encode!([[:is_not, "event:props:author", ["(none)"]]]) + + conn = + get( + conn, + "/api/stats/#{site.domain}/pages?period=day&date=2021-01-01&filters=#{filters}&detailed=true" + ) + + assert json_response(conn, 200)["results"] == [ + %{ + "name" => "/blog/other-post", + "visitors" => 2, + "pageviews" => 2, + "bounce_rate" => 100, + "time_on_page" => 30, + "scroll_depth" => 0, + "percentage" => 100.0 + }, + %{ + "name" => "/blog/john-1", + "visitors" => 1, + "pageviews" => 1, + "bounce_rate" => 0, + "time_on_page" => 60, + "scroll_depth" => 0, + "percentage" => 50.0 + } + ] + end + + test "returns top pages with :not_member filter on custom pageview props", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:pageview, + pathname: "/chrome", + "meta.key": ["browser"], + "meta.value": ["Chrome"], + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/chrome", + "meta.key": ["browser"], + "meta.value": ["Chrome"], + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/safari", + "meta.key": ["browser"], + "meta.value": ["Safari"], + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/firefox", + "meta.key": ["browser"], + "meta.value": ["Firefox"], + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/firefox", + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + filters = Jason.encode!([[:is_not, "event:props:browser", ["Chrome", "Safari"]]]) + + conn = + get(conn, "/api/stats/#{site.domain}/pages?period=day&date=2021-01-01&filters=#{filters}") + + assert json_response(conn, 200)["results"] == [ + %{ + "name" => "/firefox", + "visitors" => 2, + "percentage" => 100.0 + } + ] + + assert json_response(conn, 200)["meta"] == %{"date_range_label" => "1 Jan 2021"} + end + + test "returns top pages with :not_member filter on custom pageview props including (none) value", + %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + pathname: "/chrome", + "meta.key": ["browser"], + "meta.value": ["Chrome"], + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/chrome", + "meta.key": ["browser"], + "meta.value": ["Chrome"], + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/safari", + "meta.key": ["browser"], + "meta.value": ["Safari"], + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/no-browser-prop", + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + filters = Jason.encode!([[:is_not, "event:props:browser", ["Chrome", "(none)"]]]) + + conn = + get(conn, "/api/stats/#{site.domain}/pages?period=day&date=2021-01-01&filters=#{filters}") + + assert json_response(conn, 200)["results"] == [ + %{ + "name" => "/safari", + "visitors" => 1, + "percentage" => 100.0 + } + ] + end + + test "calculates bounce_rate and time_on_page for pages filtered by page path", + %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + pathname: "/", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:engagement, + pathname: "/", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:01:00], + engagement_time: 60_000 + ), + build(:pageview, + pathname: "/about", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:01:00] + ), + build(:engagement, + pathname: "/about", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:02:00], + engagement_time: 60_000 + ), + build(:pageview, + pathname: "/", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:02:00] + ), + build(:engagement, + pathname: "/", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:02:30], + engagement_time: 30_000 + ), + build(:pageview, + pathname: "/", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/about", + user_id: 456, + timestamp: ~N[2021-01-01 00:10:00] + ), + build(:engagement, + pathname: "/about", + user_id: 456, + timestamp: ~N[2021-01-01 00:10:30], + engagement_time: 30_000 + ) + ]) + + filters = Jason.encode!([[:is, "event:page", ["/"]]]) + + conn = + get( + conn, + "/api/stats/#{site.domain}/pages?period=day&date=2021-01-01&filters=#{filters}&detailed=true" + ) + + assert json_response(conn, 200)["results"] == [ + %{ + "name" => "/", + "visitors" => 2, + "pageviews" => 3, + "bounce_rate" => 50, + "time_on_page" => 90, + "scroll_depth" => 0, + "percentage" => 100.0 + } + ] + end + + test "calculates scroll_depth", %{conn: conn, site: site} do + t0 = ~N[2020-01-01 00:00:00] + [t1, t2, t3] = for i <- 1..3, do: NaiveDateTime.add(t0, i, :minute) + + populate_stats(site, [ + build(:pageview, user_id: 12, pathname: "/blog", timestamp: t0), + build(:engagement, + user_id: 12, + pathname: "/blog", + timestamp: t1, + scroll_depth: 20, + engagement_time: 60_000 + ), + build(:pageview, user_id: 12, pathname: "/another", timestamp: t1), + build(:engagement, + user_id: 12, + pathname: "/another", + timestamp: t2, + scroll_depth: 24, + engagement_time: 60_000 + ), + build(:pageview, user_id: 34, pathname: "/blog", timestamp: t0), + build(:engagement, + user_id: 34, + pathname: "/blog", + timestamp: t1, + scroll_depth: 17, + engagement_time: 60_000 + ), + build(:pageview, user_id: 34, pathname: "/another", timestamp: t1), + build(:engagement, + user_id: 34, + pathname: "/another", + timestamp: t2, + scroll_depth: 26, + engagement_time: 60_000 + ), + build(:pageview, user_id: 34, pathname: "/blog", timestamp: t2), + build(:engagement, + user_id: 34, + pathname: "/blog", + timestamp: t3, + scroll_depth: 60, + engagement_time: 60_000 + ), + build(:pageview, user_id: 56, pathname: "/blog", timestamp: t0), + build(:engagement, + user_id: 56, + pathname: "/blog", + timestamp: t1, + scroll_depth: 100, + engagement_time: 60_000 + ) + ]) + + conn = + get( + conn, + "/api/stats/#{site.domain}/pages?period=day&date=2020-01-01&detailed=true&order_by=#{Jason.encode!([["scroll_depth", "asc"]])}" + ) + + assert json_response(conn, 200)["results"] == [ + %{ + "name" => "/another", + "visitors" => 2, + "pageviews" => 2, + "bounce_rate" => 0, + "time_on_page" => 60, + "scroll_depth" => 25, + "percentage" => 66.67 + }, + %{ + "name" => "/blog", + "visitors" => 3, + "pageviews" => 4, + "bounce_rate" => 33, + "time_on_page" => 80, + "scroll_depth" => 60, + "percentage" => 100.0 + } + ] + end + + test "calculates scroll_depth from native and imported data combined", %{ + conn: conn, + site: site, + site_import: site_import + } do + populate_stats(site, [ + build(:pageview, + user_id: @user_id, + pathname: "/blog", + timestamp: ~N[2020-01-01 00:00:00] + ), + build(:engagement, + user_id: @user_id, + pathname: "/blog", + timestamp: ~N[2020-01-01 00:00:00], + scroll_depth: 80, + engagement_time: 20_000 + ), + build(:imported_pages, + date: ~D[2020-01-01], + visitors: 3, + pageviews: 3, + total_time_on_page: 90, + total_time_on_page_visits: 3, + page: "/blog", + total_scroll_depth: 120, + total_scroll_depth_visits: 3 + ) + ]) + + populate_stats(site, site_import.id, [ + build(:imported_visitors, date: ~D[2020-01-01]), + build(:imported_visitors, date: ~D[2020-01-01]), + build(:imported_visitors, date: ~D[2020-01-01]) + ]) + + conn = + get( + conn, + "/api/stats/#{site.domain}/pages?period=day&date=2020-01-01&detailed=true&with_imported=true&order_by=#{Jason.encode!([["scroll_depth", "desc"]])}" + ) + + assert json_response(conn, 200)["results"] == [ + %{ + "name" => "/blog", + "visitors" => 4, + "pageviews" => 4, + "bounce_rate" => 100, + "time_on_page" => 28, + "scroll_depth" => 50, + "percentage" => 100.0 + } + ] + end + + test "handles missing scroll_depth data from native and imported sources", %{ + conn: conn, + site: site, + site_import: site_import + } do + populate_stats(site, [ + build(:pageview, + user_id: @user_id, + pathname: "/native-and-imported", + timestamp: ~N[2020-01-01 00:00:00] + ), + build(:engagement, + user_id: @user_id, + pathname: "/native-and-imported", + timestamp: ~N[2020-01-01 00:01:00], + scroll_depth: 80, + engagement_time: 60_000 + ), + build(:pageview, + user_id: @user_id, + pathname: "/native-only", + timestamp: ~N[2020-01-01 00:01:00] + ), + build(:engagement, + user_id: @user_id, + pathname: "/native-only", + timestamp: ~N[2020-01-01 00:02:00], + scroll_depth: 40, + engagement_time: 60_000 + ), + build(:imported_pages, + date: ~D[2020-01-01], + visitors: 4, + pageviews: 4, + total_time_on_page: 180, + total_time_on_page_visits: 4, + page: "/native-and-imported", + total_scroll_depth: 120, + total_scroll_depth_visits: 3 + ), + build(:imported_pages, + date: ~D[2020-01-01], + visitors: 20, + pageviews: 30, + total_time_on_page: 300, + total_time_on_page_visits: 10, + page: "/imported-only", + total_scroll_depth: 100, + total_scroll_depth_visits: 10 + ) + ]) + + populate_stats( + site, + site_import.id, + for(_ <- 1..24, do: build(:imported_visitors, date: ~D[2020-01-01])) + ) + + conn = + get( + conn, + "/api/stats/#{site.domain}/pages?period=day&date=2020-01-01&detailed=true&with_imported=true&order_by=#{Jason.encode!([["scroll_depth", "desc"]])}" + ) + + assert json_response(conn, 200)["results"] == [ + %{ + "name" => "/native-and-imported", + "visitors" => 5, + "pageviews" => 5, + "bounce_rate" => 0, + "time_on_page" => 48, + "scroll_depth" => 50, + "percentage" => 20.0 + }, + %{ + "name" => "/native-only", + "visitors" => 1, + "pageviews" => 1, + "bounce_rate" => 0, + "time_on_page" => 60, + "scroll_depth" => 40, + "percentage" => 4.0 + }, + %{ + "name" => "/imported-only", + "visitors" => 20, + "pageviews" => 30, + "bounce_rate" => 0, + "time_on_page" => 30, + "scroll_depth" => 10, + "percentage" => 80.0 + } + ] + end + + test "can query scroll depth and time-on-page only from imported data, ignoring rows where scroll depth doesn't exist", + %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:imported_pages, + date: ~D[2020-01-01], + visitors: 10, + pageviews: 10, + page: "/blog", + total_scroll_depth: 100, + total_scroll_depth_visits: 10, + total_time_on_page: 300, + total_time_on_page_visits: 5 + ), + build(:imported_pages, + date: ~D[2020-01-01], + visitors: 100, + pageviews: 150, + page: "/blog", + total_scroll_depth: 0, + total_scroll_depth_visits: 0, + total_time_on_page: 0, + total_time_on_page_visits: 0 + ) + ]) + + conn = + get( + conn, + "/api/stats/#{site.domain}/pages?period=7d&date=2020-01-02&detailed=true&with_imported=true" + ) + + assert json_response(conn, 200)["results"] == [ + %{ + "name" => "/blog", + "visitors" => 110, + "pageviews" => 160, + "bounce_rate" => 0, + "time_on_page" => 60, + "scroll_depth" => 10, + "percentage" => nil + } + ] + end + + test "can filter using the | (OR) filter", + %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + pathname: "/", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:engagement, + pathname: "/", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:01:00], + engagement_time: 60_000 + ), + build(:pageview, + pathname: "/irrelevant", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:01:00] + ), + build(:engagement, + pathname: "/irrelevant", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:02:00], + engagement_time: 60_000 + ), + build(:pageview, + pathname: "/", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:02:00] + ), + build(:engagement, + pathname: "/", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:02:30], + engagement_time: 30_000 + ), + build(:pageview, + pathname: "/", + user_id: 123, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:engagement, + pathname: "/", + user_id: 123, + timestamp: ~N[2021-01-01 00:01:00], + engagement_time: 60_000 + ), + build(:pageview, + pathname: "/about", + user_id: 456, + timestamp: ~N[2021-01-01 00:10:00] + ), + build(:engagement, + pathname: "/about", + user_id: 456, + timestamp: ~N[2021-01-01 00:10:30], + engagement_time: 30_000 + ) + ]) + + filters = Jason.encode!([[:is, "event:page", ["/", "/about"]]]) + + conn = + get( + conn, + "/api/stats/#{site.domain}/pages?period=day&date=2021-01-01&filters=#{filters}&detailed=true" + ) + + assert json_response(conn, 200)["results"] == [ + %{ + "name" => "/", + "visitors" => 2, + "pageviews" => 3, + "bounce_rate" => 50, + "time_on_page" => 75, + "scroll_depth" => 0, + "percentage" => 66.67 + }, + %{ + "name" => "/about", + "visitors" => 1, + "pageviews" => 1, + "bounce_rate" => 100, + "time_on_page" => 30, + "scroll_depth" => 0, + "percentage" => 33.33 + } + ] + end + + test "can filter using the not_member filter type", + %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + pathname: "/", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:engagement, + pathname: "/", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:01:00], + engagement_time: 60_000 + ), + build(:pageview, + pathname: "/irrelevant", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:01:00] + ), + build(:engagement, + pathname: "/irrelevant", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:02:00], + engagement_time: 60_000 + ), + build(:pageview, + pathname: "/", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:02:00] + ), + build(:engagement, + pathname: "/", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:02:30], + engagement_time: 30_000 + ), + build(:pageview, + pathname: "/", + user_id: 123, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:engagement, + pathname: "/", + user_id: 123, + timestamp: ~N[2021-01-01 00:01:00], + engagement_time: 60_000 + ), + build(:pageview, + pathname: "/about", + user_id: 456, + timestamp: ~N[2021-01-01 00:10:00] + ), + build(:engagement, + pathname: "/about", + user_id: 456, + timestamp: ~N[2021-01-01 00:10:30], + engagement_time: 30_000 + ) + ]) + + filters = Jason.encode!([[:is_not, "event:page", ["/irrelevant", "/about"]]]) + + conn = + get( + conn, + "/api/stats/#{site.domain}/pages?period=day&date=2021-01-01&filters=#{filters}&detailed=true" + ) + + assert json_response(conn, 200)["results"] == [ + %{ + "name" => "/", + "visitors" => 2, + "pageviews" => 3, + "bounce_rate" => 50, + "time_on_page" => 75, + "scroll_depth" => 0, + "percentage" => 100.0 + } + ] + end + + test "can filter using the matches_member filter type", + %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + pathname: "/blog/post-1", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:engagement, + pathname: "/blog/post-1", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:01:00], + engagement_time: 60_000 + ), + build(:pageview, + pathname: "/blog/post-2", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:01:00] + ), + build(:engagement, + pathname: "/blog/post-2", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:01:30], + engagement_time: 30_000 + ), + build(:pageview, + pathname: "/", + user_id: 100, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:engagement, + pathname: "/", + user_id: 100, + timestamp: ~N[2021-01-01 00:00:30], + engagement_time: 30_000 + ), + build(:pageview, + pathname: "/articles/post-1", + user_id: 200, + timestamp: ~N[2021-01-01 00:10:00] + ), + build(:engagement, + pathname: "/articles/post-1", + user_id: 200, + timestamp: ~N[2021-01-01 00:10:30], + engagement_time: 30_000 + ), + build(:pageview, + pathname: "/articles/post-1", + user_id: 300, + timestamp: ~N[2021-01-01 00:10:00] + ), + build(:engagement, + pathname: "/articles/post-1", + user_id: 300, + timestamp: ~N[2021-01-01 00:10:30], + engagement_time: 30_000 + ) + ]) + + filters = Jason.encode!([[:contains, "event:page", ["/blog/", "/articles/"]]]) + + conn = + get( + conn, + "/api/stats/#{site.domain}/pages?period=day&date=2021-01-01&filters=#{filters}&detailed=true" + ) + + assert json_response(conn, 200)["results"] == [ + %{ + "name" => "/articles/post-1", + "visitors" => 2, + "pageviews" => 2, + "bounce_rate" => 100, + "time_on_page" => 30, + "scroll_depth" => 0, + "percentage" => 66.67 + }, + %{ + "name" => "/blog/post-1", + "visitors" => 1, + "pageviews" => 1, + "bounce_rate" => 0, + "time_on_page" => 60, + "scroll_depth" => 0, + "percentage" => 33.33 + }, + %{ + "name" => "/blog/post-2", + "visitors" => 1, + "pageviews" => 1, + "bounce_rate" => 0, + "time_on_page" => 30, + "scroll_depth" => 0, + "percentage" => 33.33 + } + ] + end + + test "page filter escapes brackets", + %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + pathname: "/blog/(/post-1", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:engagement, + pathname: "/blog/(/post-1", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:01:00], + engagement_time: 60_000 + ), + build(:pageview, + pathname: "/blog/(/post-2", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:01:00] + ), + build(:engagement, + pathname: "/blog/(/post-2", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:01:30], + engagement_time: 30_000 + ), + build(:pageview, + pathname: "/", + user_id: 456, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:engagement, + pathname: "/", + user_id: 456, + timestamp: ~N[2021-01-01 00:00:30], + engagement_time: 30_000 + ) + ]) + + filters = Jason.encode!([[:contains, "event:page", ["/blog/(/", "/blog/)/"]]]) + + conn = + get( + conn, + "/api/stats/#{site.domain}/pages?period=day&date=2021-01-01&filters=#{filters}&detailed=true" + ) + + assert json_response(conn, 200)["results"] == [ + %{ + "name" => "/blog/(/post-1", + "visitors" => 1, + "pageviews" => 1, + "bounce_rate" => 0, + "time_on_page" => 60, + "scroll_depth" => 0, + "percentage" => 100.0 + }, + %{ + "name" => "/blog/(/post-2", + "visitors" => 1, + "pageviews" => 1, + "bounce_rate" => 0, + "time_on_page" => 30, + "scroll_depth" => 0, + "percentage" => 100.0 + } + ] + end + + test "can filter using the not_matches_member filter type", + %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + pathname: "/blog/post-1", + user_id: 100, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:engagement, + pathname: "/blog/post-1", + user_id: 100, + timestamp: ~N[2021-01-01 00:00:30], + engagement_time: 30_000 + ), + build(:pageview, + pathname: "/", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:engagement, + pathname: "/", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:10:00], + engagement_time: 600_000 + ), + build(:pageview, + pathname: "/about", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:10:00] + ), + build(:engagement, + pathname: "/about", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:10:30], + engagement_time: 30_000 + ), + build(:pageview, + pathname: "/", + user_id: 200, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:engagement, + pathname: "/", + user_id: 200, + timestamp: ~N[2021-01-01 00:10:00], + engagement_time: 600_000 + ), + build(:pageview, + pathname: "/articles/post-1", + user_id: 300, + timestamp: ~N[2021-01-01 00:10:00] + ), + build(:engagement, + pathname: "/articles/post-1", + user_id: 300, + timestamp: ~N[2021-01-01 00:10:30], + engagement_time: 30_000 + ) + ]) + + filters = Jason.encode!([[:contains_not, "event:page", ["/blog/", "/articles/"]]]) + + conn = + get( + conn, + "/api/stats/#{site.domain}/pages?period=day&date=2021-01-01&filters=#{filters}&detailed=true" + ) + + assert json_response(conn, 200)["results"] == [ + %{ + "name" => "/", + "visitors" => 2, + "pageviews" => 2, + "bounce_rate" => 50, + "time_on_page" => 600, + "scroll_depth" => 0, + "percentage" => 100.0 + }, + %{ + "name" => "/about", + "visitors" => 1, + "pageviews" => 1, + "bounce_rate" => 0, + "time_on_page" => 30, + "scroll_depth" => 0, + "percentage" => 50.0 + } + ] + end + + test "returns top pages by visitors with imported data", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, pathname: "/"), + build(:pageview, pathname: "/"), + build(:pageview, pathname: "/"), + build(:imported_pages, page: "/"), + build(:pageview, pathname: "/register"), + build(:pageview, pathname: "/register"), + build(:imported_pages, page: "/register"), + build(:pageview, pathname: "/contact") + ]) + + conn1 = get(conn, "/api/stats/#{site.domain}/pages?period=day") + + assert json_response(conn1, 200)["results"] == [ + %{"visitors" => 3, "name" => "/", "percentage" => 50.0}, + %{"visitors" => 2, "name" => "/register", "percentage" => 33.33}, + %{"visitors" => 1, "name" => "/contact", "percentage" => 16.67} + ] + + conn2 = get(conn, "/api/stats/#{site.domain}/pages?period=day&with_imported=true") + + assert json_response(conn2, 200)["results"] == [ + %{"visitors" => 4, "name" => "/", "percentage" => 66.67}, + %{"visitors" => 3, "name" => "/register", "percentage" => 50.0}, + %{"visitors" => 1, "name" => "/contact", "percentage" => 16.67} + ] + end + + test "returns scroll depth warning code", %{conn: conn, site: site} do + conn = + get(conn, "/api/stats/#{site.domain}/pages?period=day&detailed=true&with_imported=true") + + response = json_response(conn, 200) + + assert response["meta"]["metric_warnings"]["scroll_depth"]["code"] == + "no_imported_scroll_depth" + end + + test "returns imported pages with a pageview goal filter", %{conn: conn, site: site} do + insert(:goal, site: site, page_path: "/blog**") + + populate_stats(site, [ + build(:imported_pages, page: "/blog"), + build(:imported_pages, page: "/not-this"), + build(:imported_pages, page: "/blog/post-1", visitors: 2), + build(:imported_visitors, visitors: 4) + ]) + + filters = Jason.encode!([[:is, "event:goal", ["Visit /blog**"]]]) + q = "?period=day&filters=#{filters}&with_imported=true" + conn = get(conn, "/api/stats/#{site.domain}/pages#{q}") + + assert json_response(conn, 200)["results"] == [ + %{ + "visitors" => 2, + "name" => "/blog/post-1", + "conversion_rate" => 100.0, + "total_visitors" => 2 + }, + %{ + "visitors" => 1, + "name" => "/blog", + "conversion_rate" => 100.0, + "total_visitors" => 1 + } + ] + end + + test "calculates bounce rate and time on page for pages", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + pathname: "/", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:engagement, + pathname: "/", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:15:00], + engagement_time: 900_000 + ), + build(:pageview, + pathname: "/some-other-page", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:15:00] + ), + build(:engagement, + pathname: "/some-other-page", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:15:30], + engagement_time: 30_000 + ), + build(:pageview, + pathname: "/", + user_id: 123, + timestamp: ~N[2021-01-01 00:15:00] + ), + build(:engagement, + pathname: "/", + user_id: 123, + timestamp: ~N[2021-01-01 00:15:30], + engagement_time: 30_000 + ) + ]) + + conn = + get( + conn, + "/api/stats/#{site.domain}/pages?period=day&date=2021-01-01&detailed=true" + ) + + assert json_response(conn, 200)["results"] == [ + %{ + "bounce_rate" => 50.0, + "time_on_page" => 465.0, + "visitors" => 2, + "pageviews" => 2, + "name" => "/", + "scroll_depth" => 0, + "percentage" => 100.0 + }, + %{ + "bounce_rate" => 0, + "time_on_page" => 30, + "visitors" => 1, + "pageviews" => 1, + "name" => "/some-other-page", + "scroll_depth" => 0, + "percentage" => 50.0 + } + ] + end + + test "filtering by hostname, excludes a page on different hostname", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + timestamp: ~N[2021-01-01 05:01:00], + pathname: "/about", + hostname: "blog.example.com", + user_id: @user_id + ), + build(:pageview, + timestamp: ~N[2021-01-01 05:01:02], + pathname: "/hello", + hostname: "example.com", + user_id: @user_id + ), + build(:pageview, + timestamp: ~N[2021-01-01 05:01:02], + pathname: "/about", + hostname: "blog.example.com" + ) + ]) + + filters = Jason.encode!([[:is, "event:hostname", ["blog.example.com"]]]) + + conn = + get( + conn, + "/api/stats/#{site.domain}/pages?period=day&date=2021-01-01&detailed=true&filters=#{filters}" + ) + + assert json_response(conn, 200)["results"] == [ + %{ + "bounce_rate" => 50, + "name" => "/about", + "pageviews" => 2, + "time_on_page" => nil, + "visitors" => 2, + "scroll_depth" => nil, + "percentage" => 100.0 + } + ] + end + + test "calculates bounce rate and time on page for pages when filtered by hostname", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + # session 1 + build(:pageview, + pathname: "/about-blog", + hostname: "blog.example.com", + user_id: @user_id + 1, + timestamp: ~N[2021-01-01 00:01:00] + ), + build(:engagement, + pathname: "/about-blog", + hostname: "blog.example.com", + user_id: @user_id + 1, + timestamp: ~N[2021-01-01 00:01:30], + engagement_time: 30_000 + ), + + # session 2 + build(:pageview, + pathname: "/about-blog", + hostname: "blog.example.com", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:01:00] + ), + build(:engagement, + pathname: "/about-blog", + hostname: "blog.example.com", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:10:00], + engagement_time: 540_000 + ), + build(:pageview, + pathname: "/about", + hostname: "example.com", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:10:00] + ), + build(:engagement, + pathname: "/about", + hostname: "example.com", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:15:00], + engagement_time: 300_000 + ), + build(:pageview, + pathname: "/about-blog", + hostname: "blog.example.com", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:15:00] + ), + build(:engagement, + pathname: "/about-blog", + hostname: "blog.example.com", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:20:00], + engagement_time: 300_000 + ), + build(:pageview, + pathname: "/exit-blog", + hostname: "blog.example.com", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:20:00] + ), + build(:engagement, + pathname: "/exit-blog", + hostname: "blog.example.com", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:22:00], + engagement_time: 120_000 + ), + build(:pageview, + pathname: "/about", + hostname: "example.com", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:22:00] + ), + build(:engagement, + pathname: "/about", + hostname: "example.com", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:25:00], + engagement_time: 180_000 + ), + build(:pageview, + pathname: "/exit", + hostname: "example.com", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:25:00] + ), + build(:engagement, + pathname: "/exit", + hostname: "example.com", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:25:30], + engagement_time: 30_000 + ), + + # session 3 + build(:pageview, + pathname: "/about", + hostname: "example.com", + user_id: @user_id + 2, + timestamp: ~N[2021-01-01 00:01:00] + ), + build(:engagement, + pathname: "/about", + hostname: "example.com", + user_id: @user_id + 2, + timestamp: ~N[2021-01-01 00:01:30], + engagement_time: 30_000 + ) + ]) + + filters = Jason.encode!([[:is, "event:hostname", ["blog.example.com"]]]) + + conn = + get( + conn, + "/api/stats/#{site.domain}/pages?period=day&date=2021-01-01&detailed=true&filters=#{filters}" + ) + + assert json_response(conn, 200)["results"] == [ + %{ + "bounce_rate" => 50, + "name" => "/about-blog", + "pageviews" => 3, + "time_on_page" => 435, + "visitors" => 2, + "scroll_depth" => 0, + "percentage" => 100.0 + }, + %{ + "bounce_rate" => 0, + "name" => "/exit-blog", + "pageviews" => 1, + "time_on_page" => 120, + "visitors" => 1, + "scroll_depth" => 0, + "percentage" => 50.0 + } + ] + end + + test "calculates bounce rate and time on page for pages with imported data", %{ + conn: conn, + site: site, + site_import: site_import + } do + populate_stats(site, [ + build(:pageview, + pathname: "/", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:engagement, + pathname: "/", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:15:00], + engagement_time: 900_000 + ), + build(:pageview, + pathname: "/some-other-page", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:15:00] + ), + build(:engagement, + pathname: "/some-other-page", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:15:30], + engagement_time: 30_000 + ), + build(:pageview, + pathname: "/", + user_id: 123, + timestamp: ~N[2021-01-01 00:15:00] + ), + build(:engagement, + pathname: "/", + user_id: 123, + timestamp: ~N[2021-01-01 00:30:00], + engagement_time: 900_000 + ), + build(:imported_pages, + page: "/", + date: ~D[2021-01-01], + total_time_on_page: 700, + total_time_on_page_visits: 3 + ), + build(:imported_entry_pages, + entry_page: "/", + date: ~D[2021-01-01], + entrances: 3, + bounces: 1 + ), + build(:imported_pages, + page: "/some-other-page", + date: ~D[2021-01-01], + total_time_on_page: 60, + total_time_on_page_visits: 1 + ) + ]) + + populate_stats(site, site_import.id, [ + build(:imported_visitors, date: ~D[2021-01-01]), + build(:imported_visitors, date: ~D[2021-01-01]), + build(:imported_visitors, date: ~D[2021-01-01]) + ]) + + conn = + get( + conn, + "/api/stats/#{site.domain}/pages?period=day&date=2021-01-01&detailed=true&with_imported=true" + ) + + assert json_response(conn, 200)["results"] == [ + %{ + "bounce_rate" => 40.0, + "time_on_page" => 500, + "visitors" => 3, + "pageviews" => 3, + "scroll_depth" => 0, + "name" => "/", + "percentage" => 60.0 + }, + %{ + "bounce_rate" => 0, + "time_on_page" => 45, + "visitors" => 2, + "pageviews" => 2, + "scroll_depth" => 0, + "name" => "/some-other-page", + "percentage" => 40.0 + } + ] + end + + test "returns top pages in realtime report", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, pathname: "/page1"), + build(:pageview, pathname: "/page2"), + build(:pageview, pathname: "/page1") + ]) + + conn = get(conn, "/api/stats/#{site.domain}/pages?period=realtime") + + assert json_response(conn, 200)["results"] == [ + %{"visitors" => 2, "name" => "/page1", "percentage" => 66.67}, + %{"visitors" => 1, "name" => "/page2", "percentage" => 33.33} + ] + end + + test "calculates conversion_rate when filtering for goal", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, user_id: 1, pathname: "/"), + build(:pageview, user_id: 2, pathname: "/"), + build(:pageview, user_id: 3, pathname: "/"), + build(:event, user_id: 3, name: "Signup") + ]) + + insert(:goal, site: site, event_name: "Signup") + filters = Jason.encode!([[:is, "event:goal", ["Signup"]]]) + + conn = get(conn, "/api/stats/#{site.domain}/pages?period=day&filters=#{filters}") + + assert json_response(conn, 200)["results"] == [ + %{ + "total_visitors" => 3, + "visitors" => 1, + "name" => "/", + "conversion_rate" => 33.33 + } + ] + end + + test "filter by :is page with imported data", %{conn: conn, site: site} do + site_import = insert(:site_import, site: site) + + populate_stats(site, site_import.id, [ + build(:pageview, user_id: 1, pathname: "/", timestamp: ~N[2021-01-01 12:00:00]), + build(:engagement, + user_id: 1, + pathname: "/", + timestamp: ~N[2021-01-01 12:01:00], + engagement_time: 60_000 + ), + build(:pageview, user_id: 1, pathname: "/ignored", timestamp: ~N[2021-01-01 12:01:00]), + build(:engagement, + user_id: 1, + pathname: "/ignored", + timestamp: ~N[2021-01-01 12:02:00], + engagement_time: 60_000 + ), + build(:imported_entry_pages, + entry_page: "/", + visitors: 1, + bounces: 1, + date: ~D[2021-01-01] + ), + build(:imported_pages, + page: "/", + visitors: 3, + pageviews: 3, + total_time_on_page: 300, + total_time_on_page_visits: 3, + date: ~D[2021-01-01] + ), + build(:imported_pages, page: "/ignored", visitors: 10, date: ~D[2021-01-01]) + ]) + + filters = Jason.encode!([[:is, "event:page", ["/"]]]) + q = "?period=day&date=2021-01-01&filters=#{filters}&detailed=true&with_imported=true" + + conn = get(conn, "/api/stats/#{site.domain}/pages#{q}") + + assert json_response(conn, 200)["results"] == [ + %{ + "bounce_rate" => 50, + "name" => "/", + "pageviews" => 4, + "time_on_page" => 90.0, + "visitors" => 4, + "scroll_depth" => 0, + "percentage" => 100.0 + } + ] + end + + test "filter by :member page with imported data", %{conn: conn, site: site} do + site_import = insert(:site_import, site: site) + + populate_stats(site, site_import.id, [ + build(:pageview, user_id: 1, pathname: "/", timestamp: ~N[2021-01-01 12:00:00]), + build(:engagement, + user_id: 1, + pathname: "/", + timestamp: ~N[2021-01-01 12:01:00], + engagement_time: 60_000 + ), + build(:pageview, user_id: 1, pathname: "/ignored", timestamp: ~N[2021-01-01 12:01:00]), + build(:engagement, + user_id: 1, + pathname: "/ignored", + timestamp: ~N[2021-01-01 12:02:00], + engagement_time: 60_000 + ), + build(:imported_entry_pages, + entry_page: "/", + visitors: 1, + bounces: 1, + date: ~D[2021-01-01] + ), + build(:imported_entry_pages, + entry_page: "/a", + visitors: 1, + bounces: 1, + date: ~D[2021-01-01] + ), + build(:imported_pages, + page: "/", + visitors: 3, + pageviews: 3, + total_time_on_page: 300, + total_time_on_page_visits: 3, + date: ~D[2021-01-01] + ), + build(:imported_pages, + page: "/a", + visitors: 1, + date: ~D[2021-01-01] + ), + build(:imported_pages, page: "/ignored", visitors: 10, date: ~D[2021-01-01]) + ]) + + filters = Jason.encode!([[:is, "event:page", ["/", "/a"]]]) + q = "?period=day&date=2021-01-01&filters=#{filters}&detailed=true&with_imported=true" + + conn = get(conn, "/api/stats/#{site.domain}/pages#{q}") + + assert json_response(conn, 200)["results"] == [ + %{ + "bounce_rate" => 50, + "name" => "/", + "pageviews" => 4, + "time_on_page" => 90.0, + "visitors" => 4, + "scroll_depth" => 0, + "percentage" => 80.0 + }, + %{ + "bounce_rate" => 100, + "name" => "/a", + "pageviews" => 1, + "time_on_page" => 10.0, + "visitors" => 1, + "scroll_depth" => nil, + "percentage" => 20.0 + } + ] + end + + test "filter by :matches_wildcard page with imported data", %{conn: conn, site: site} do + site_import = insert(:site_import, site: site) + + populate_stats(site, site_import.id, [ + build(:pageview, user_id: 1, pathname: "/aaa", timestamp: ~N[2021-01-01 12:00:00]), + build(:engagement, + user_id: 1, + pathname: "/aaa", + timestamp: ~N[2021-01-01 12:01:00], + engagement_time: 60_000 + ), + build(:pageview, user_id: 1, pathname: "/ignored", timestamp: ~N[2021-01-01 12:01:00]), + build(:engagement, + user_id: 1, + pathname: "/ignored", + timestamp: ~N[2021-01-01 12:02:00], + engagement_time: 60_000 + ), + build(:imported_entry_pages, + entry_page: "/aaa", + visitors: 1, + bounces: 1, + date: ~D[2021-01-01] + ), + build(:imported_entry_pages, + entry_page: "/a", + visitors: 1, + bounces: 1, + date: ~D[2021-01-01] + ), + build(:imported_pages, + page: "/aaa", + visitors: 3, + pageviews: 3, + total_time_on_page: 300, + total_time_on_page_visits: 3, + date: ~D[2021-01-01] + ), + build(:imported_pages, + page: "/a", + visitors: 1, + date: ~D[2021-01-01] + ), + build(:imported_pages, page: "/ignored", visitors: 10, date: ~D[2021-01-01]) + ]) + + filters = Jason.encode!([[:contains, "event:page", ["/a"]]]) + q = "?period=day&date=2021-01-01&filters=#{filters}&detailed=true&with_imported=true" + + conn = get(conn, "/api/stats/#{site.domain}/pages#{q}") + + assert json_response(conn, 200)["results"] == [ + %{ + "bounce_rate" => 50.0, + "name" => "/aaa", + "pageviews" => 4, + "time_on_page" => 90, + "visitors" => 4, + "scroll_depth" => 0, + "percentage" => 80.0 + }, + %{ + "bounce_rate" => 100.0, + "name" => "/a", + "pageviews" => 1, + "time_on_page" => 10, + "visitors" => 1, + "scroll_depth" => nil, + "percentage" => 20.0 + } + ] + end + + test "can compare with previous period", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + pathname: "/page1", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/page1", + timestamp: ~N[2021-01-02 00:00:00] + ), + build(:pageview, + pathname: "/page2", + timestamp: ~N[2021-01-02 00:00:00] + ), + build(:pageview, + pathname: "/page2", + timestamp: ~N[2021-01-02 00:00:00] + ) + ]) + + conn = + get( + conn, + "/api/stats/#{site.domain}/pages?period=day&date=2021-01-02&comparison=previous_period&detailed=true" + ) + + assert json_response(conn, 200)["results"] == [ + %{ + "bounce_rate" => 100, + "comparison" => %{ + "bounce_rate" => 0.0, + "pageviews" => 0, + "time_on_page" => nil, + "visitors" => 0, + "scroll_depth" => nil, + "percentage" => 0.0, + "change" => %{ + "bounce_rate" => nil, + "pageviews" => 100, + "time_on_page" => nil, + "visitors" => 100, + "scroll_depth" => nil, + "percentage" => 100 + } + }, + "name" => "/page2", + "pageviews" => 2, + "time_on_page" => nil, + "visitors" => 2, + "scroll_depth" => nil, + "percentage" => 66.67 + }, + %{ + "bounce_rate" => 100, + "name" => "/page1", + "pageviews" => 1, + "time_on_page" => nil, + "visitors" => 1, + "scroll_depth" => nil, + "percentage" => 33.33, + "comparison" => %{ + "bounce_rate" => 100, + "pageviews" => 1, + "time_on_page" => nil, + "visitors" => 1, + "scroll_depth" => nil, + "percentage" => 100.0, + "change" => %{ + "bounce_rate" => 0, + "pageviews" => 0, + "time_on_page" => nil, + "visitors" => 0, + "scroll_depth" => nil, + "percentage" => -67 + } + } + } + ] + + assert json_response(conn, 200)["meta"] == %{ + "date_range_label" => "2 Jan 2021", + "comparison_date_range_label" => "1 Jan 2021" + } + end + + on_ee do + test "returns pages across all sites on a consolidated view", %{conn: conn, site: site} do + another_site = new_site(team: site.team) + cv = new_consolidated_view(site.team) + + populate_stats(site, [ + build(:pageview, pathname: "/a1", timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, pathname: "/a2", timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, pathname: "/a2", timestamp: ~N[2021-01-01 00:00:00]) + ]) + + populate_stats(another_site, [ + build(:pageview, pathname: "/b1", timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, pathname: "/b1", timestamp: ~N[2021-01-01 00:00:00]), + build(:pageview, pathname: "/b1", timestamp: ~N[2021-01-01 00:00:00]) + ]) + + conn = + get( + conn, + "/api/stats/#{cv.domain}/pages?period=day&date=2021-01-01&detailed=true" + ) + + assert json_response(conn, 200)["results"] == [ + %{ + "bounce_rate" => 100, + "name" => "/b1", + "pageviews" => 3, + "time_on_page" => nil, + "visitors" => 3, + "scroll_depth" => nil, + "percentage" => 50.0 + }, + %{ + "bounce_rate" => 100, + "name" => "/a2", + "pageviews" => 2, + "time_on_page" => nil, + "visitors" => 2, + "scroll_depth" => nil, + "percentage" => 33.33 + }, + %{ + "bounce_rate" => 100, + "name" => "/a1", + "pageviews" => 1, + "time_on_page" => nil, + "visitors" => 1, + "scroll_depth" => nil, + "percentage" => 16.67 + } + ] + end + end + end + + describe "GET /api/stats/:domain/entry-pages" do + setup [:create_user, :log_in, :create_site, :create_legacy_site_import] + + test "returns top entry pages by visitors", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + pathname: "/page1", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/page1", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/page2", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/page2", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:15:00] + ) + ]) + + populate_stats(site, [ + build(:pageview, + pathname: "/page2", + user_id: @user_id, + timestamp: ~N[2021-01-01 23:15:00] + ) + ]) + + conn = get(conn, "/api/stats/#{site.domain}/entry-pages?period=day&date=2021-01-01") + + assert json_response(conn, 200)["results"] == [ + %{ + "visitors" => 2, + "visits" => 2, + "name" => "/page1", + "visit_duration" => 0, + "bounce_rate" => 100, + "percentage" => 66.67 + }, + %{ + "visitors" => 1, + "visits" => 2, + "name" => "/page2", + "visit_duration" => 450, + "bounce_rate" => 50, + "percentage" => 33.33 + } + ] + end + + test "returns top entry pages filtered by custom pageview props", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + pathname: "/blog", + user_id: 123, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/blog/john-1", + "meta.key": ["author"], + "meta.value": ["John Doe"], + user_id: 123, + timestamp: ~N[2021-01-01 00:01:00] + ), + build(:pageview, + pathname: "/blog/john-2", + "meta.key": ["author"], + "meta.value": ["John Doe"], + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/blog/other-post", + "meta.key": ["author"], + "meta.value": ["other"], + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/blog", + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + filters = Jason.encode!([[:is, "event:props:author", ["John Doe"]]]) + + conn = + get( + conn, + "/api/stats/#{site.domain}/entry-pages?period=day&date=2021-01-01&filters=#{filters}" + ) + + assert json_response(conn, 200)["results"] == [ + %{ + "visitors" => 1, + "visits" => 1, + "name" => "/blog", + "visit_duration" => 60, + "bounce_rate" => 0, + "percentage" => 50.0 + }, + %{ + "visitors" => 1, + "visits" => 1, + "name" => "/blog/john-2", + "visit_duration" => 0, + "bounce_rate" => 100, + "percentage" => 50.0 + } + ] + end + + test "returns top entry pages by visitors with imported data", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + pathname: "/page1", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/page1", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/page2", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/page2", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:15:00] + ), + build(:pageview, + pathname: "/page2", + user_id: @user_id, + timestamp: ~N[2021-01-01 23:15:00] + ) + ]) + + populate_stats(site, [ + build(:imported_entry_pages, + entry_page: "/page2", + date: ~D[2021-01-01], + entrances: 3, + visitors: 2, + visit_duration: 300 + ) + ]) + + conn1 = get(conn, "/api/stats/#{site.domain}/entry-pages?period=day&date=2021-01-01") + + assert json_response(conn1, 200)["results"] == [ + %{ + "visitors" => 2, + "visits" => 2, + "name" => "/page1", + "visit_duration" => 0, + "bounce_rate" => 100, + "percentage" => 66.67 + }, + %{ + "visitors" => 1, + "visits" => 2, + "name" => "/page2", + "visit_duration" => 450, + "bounce_rate" => 50, + "percentage" => 33.33 + } + ] + + conn2 = + get( + conn, + "/api/stats/#{site.domain}/entry-pages?period=day&date=2021-01-01&with_imported=true" + ) + + assert json_response(conn2, 200)["results"] == [ + %{ + "visitors" => 3, + "visits" => 5, + "name" => "/page2", + "visit_duration" => 240.0, + "bounce_rate" => 20.0, + "percentage" => 60.0 + }, + %{ + "visitors" => 2, + "visits" => 2, + "name" => "/page1", + "visit_duration" => 0.0, + "bounce_rate" => 100.0, + "percentage" => 40.0 + } + ] + end + + test "returns top entry pages by visitors filtered by hostname", + %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + pathname: "/page1", + hostname: "en.example.com", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/page1", + hostname: "es.example.com", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/page2", + hostname: "en.example.com", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/page2", + hostname: "es.example.com", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:15:00] + ), + build(:pageview, + pathname: "/exit", + hostname: "es.example.com", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:16:00] + ), + build(:pageview, + pathname: "/page2", + hostname: "es.example.com", + timestamp: ~N[2021-01-01 23:15:00] + ) + ]) + + filters = Jason.encode!([[:is, "event:hostname", ["es.example.com"]]]) + + conn = + get( + conn, + "/api/stats/#{site.domain}/entry-pages?period=day&date=2021-01-01&filters=#{filters}" + ) + + # We're going to only join sessions where the exit hostname matches the filter + assert json_response(conn, 200)["results"] == [ + %{ + "name" => "/page1", + "visit_duration" => 0, + "visitors" => 1, + "visits" => 1, + "bounce_rate" => 100, + "percentage" => 50.0 + }, + %{ + "name" => "/page2", + "visit_duration" => 0, + "visitors" => 1, + "visits" => 1, + "bounce_rate" => 100, + "percentage" => 50.0 + } + ] + end + + test "bugfix: pagination on /pages filtered by goal", %{conn: conn, site: site} do + populate_stats( + site, + for i <- 1..30 do + build(:event, + user_id: i, + name: "Signup", + pathname: "/signup/#{String.pad_leading(to_string(i), 2, "0")}", + timestamp: ~N[2021-01-01 00:01:00] + ) + end + ) + + insert(:goal, site: site, event_name: "Signup") + + request = fn conn, opts -> + page = Keyword.fetch!(opts, :page) + limit = Keyword.fetch!(opts, :limit) + filters = Jason.encode!([[:is, "event:goal", ["Signup"]]]) + + conn + |> get( + "/api/stats/#{site.domain}/pages?date=2021-01-01&period=day&filters=#{filters}&limit=#{limit}&page=#{page}" + ) + |> json_response(200) + |> Map.get("results") + |> Enum.map(fn %{"name" => "/signup/" <> seq} -> + seq + end) + end + + assert List.first(request.(conn, page: 1, limit: 100)) == "01" + assert List.last(request.(conn, page: 1, limit: 100)) == "30" + assert List.last(request.(conn, page: 1, limit: 29)) == "29" + assert ["01", "02"] = request.(conn, page: 1, limit: 2) + assert ["03", "04"] = request.(conn, page: 2, limit: 2) + assert ["01", "02", "03", "04", "05"] = request.(conn, page: 1, limit: 5) + assert ["06", "07", "08", "09", "10"] = request.(conn, page: 2, limit: 5) + assert ["11", "12", "13", "14", "15"] = request.(conn, page: 3, limit: 5) + assert ["20"] = request.(conn, page: 20, limit: 1) + assert [] = request.(conn, page: 31, limit: 1) + end + + test "calculates conversion_rate when filtering for goal", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + pathname: "/page1", + user_id: 1, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/page1", + user_id: 2, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + name: "Signup", + user_id: 1, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/page2", + user_id: 3, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/page2", + user_id: 3, + timestamp: ~N[2021-01-01 00:15:00] + ), + build(:event, + name: "Signup", + user_id: 3, + timestamp: ~N[2021-01-01 00:15:00] + ) + ]) + + insert(:goal, site: site, event_name: "Signup") + filters = Jason.encode!([[:is, "event:goal", ["Signup"]]]) + + conn = + get( + conn, + "/api/stats/#{site.domain}/entry-pages?period=day&date=2021-01-01&filters=#{filters}" + ) + + assert json_response(conn, 200)["results"] == [ + %{ + "total_visitors" => 2, + "visitors" => 1, + "name" => "/page1", + "conversion_rate" => 50.0 + }, + %{ + "total_visitors" => 1, + "visitors" => 1, + "name" => "/page2", + "conversion_rate" => 100.0 + } + ] + end + + test "ignores entry pages from sessions with only custom events", %{conn: conn, site: site} do + populate_stats(site, [ + build(:event, + name: "Signup", + timestamp: ~N[2021-01-01 00:15:00], + pathname: "/" + ) + ]) + + conn = + get( + conn, + "/api/stats/#{site.domain}/entry-pages?period=day&date=2021-01-01" + ) + + assert json_response(conn, 200)["results"] == [] + end + + test "filter by :matches_member entry_page with imported data", %{conn: conn, site: site} do + site_import = insert(:site_import, site: site) + + populate_stats(site, site_import.id, [ + build(:pageview, pathname: "/aaa", timestamp: ~N[2021-01-01 12:00:00]), + build(:pageview, pathname: "/a", timestamp: ~N[2021-01-01 12:00:00]), + build(:pageview, pathname: "/ignored", timestamp: ~N[2021-01-01 12:01:00]), + build(:imported_entry_pages, + entry_page: "/a", + visitors: 5, + entrances: 9, + visit_duration: 1000, + date: ~D[2021-01-01] + ), + build(:imported_entry_pages, + entry_page: "/bbb", + visitors: 2, + entrances: 2, + visit_duration: 100, + date: ~D[2021-01-01] + ) + ]) + + filters = Jason.encode!([[:contains, "visit:entry_page", ["/a", "/b"]]]) + q = "?period=day&date=2021-01-01&filters=#{filters}&detailed=true&with_imported=true" + + conn = get(conn, "/api/stats/#{site.domain}/entry-pages#{q}") + + assert json_response(conn, 200)["results"] == [ + %{ + "visit_duration" => 100.0, + "name" => "/a", + "visits" => 10, + "visitors" => 6, + "bounce_rate" => 10.0, + "percentage" => 66.67 + }, + %{ + "visit_duration" => 50.0, + "name" => "/bbb", + "visits" => 2, + "visitors" => 2, + "bounce_rate" => 0.0, + "percentage" => 22.22 + }, + %{ + "visit_duration" => 0, + "name" => "/aaa", + "visits" => 1, + "visitors" => 1, + "bounce_rate" => 100.0, + "percentage" => 11.11 + } + ] + end + end + + describe "GET /api/stats/:domain/exit-pages" do + setup [:create_user, :log_in, :create_site, :create_legacy_site_import] + + test "returns top exit pages by visitors", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + pathname: "/page1", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/page1", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/page1", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/page2", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:15:00] + ) + ]) + + conn = get(conn, "/api/stats/#{site.domain}/exit-pages?period=day&date=2021-01-01") + + assert json_response(conn, 200)["results"] == [ + %{ + "name" => "/page1", + "visitors" => 2, + "visits" => 2, + "exit_rate" => 66.7, + "percentage" => 66.67 + }, + %{ + "name" => "/page2", + "visitors" => 1, + "visits" => 1, + "exit_rate" => 100, + "percentage" => 33.33 + } + ] + end + + test "returns top exit pages by ascending visits", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + pathname: "/page1", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/page1", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/page1", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/page2", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:15:00] + ) + ]) + + conn = + get( + conn, + "/api/stats/#{site.domain}/exit-pages?period=day&date=2021-01-01&order_by=#{Jason.encode!([["visits", "asc"]])}" + ) + + assert json_response(conn, 200)["results"] == [ + %{ + "name" => "/page2", + "visitors" => 1, + "visits" => 1, + "exit_rate" => 100.0, + "percentage" => 33.33 + }, + %{ + "name" => "/page1", + "visitors" => 2, + "visits" => 2, + "exit_rate" => 66.7, + "percentage" => 66.67 + } + ] + end + + test "returns top exit pages by visitors filtered by hostname", + %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + pathname: "/page1", + hostname: "en.example.com", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/page1", + hostname: "es.example.com", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/page1", + hostname: "en.example.com", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/page2", + hostname: "es.example.com", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:15:00] + ), + build(:pageview, + pathname: "/exit", + hostname: "en.example.com", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:16:00] + ) + ]) + + filters = Jason.encode!([[:is, "event:hostname", ["es.example.com"]]]) + + conn = + get( + conn, + "/api/stats/#{site.domain}/exit-pages?period=day&date=2021-01-01&filters=#{filters}" + ) + + # We're going to only join sessions where the entry hostname matches the filter + assert json_response(conn, 200)["results"] == + [%{"name" => "/page1", "visitors" => 1, "visits" => 1, "percentage" => 100.0}] + end + + test "returns top exit pages filtered by custom pageview props", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + pathname: "/blog/john-1", + "meta.key": ["author"], + "meta.value": ["John Doe"], + user_id: 123, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/", + user_id: 123, + timestamp: ~N[2021-01-01 00:01:00] + ), + build(:pageview, + pathname: "/blog/other-post", + "meta.key": ["author"], + "meta.value": ["other"], + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/", + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + filters = Jason.encode!([[:is, "event:props:author", ["John Doe"]]]) + + conn = + get( + conn, + "/api/stats/#{site.domain}/exit-pages?period=day&date=2021-01-01&filters=#{filters}" + ) + + assert json_response(conn, 200)["results"] == [ + %{"name" => "/", "visitors" => 1, "visits" => 1, "percentage" => 100.0} + ] + end + + test "returns top exit pages by visitors with imported data", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + pathname: "/page1", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/page1", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/page1", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + pathname: "/page2", + user_id: @user_id, + timestamp: ~N[2021-01-01 00:15:00] + ) + ]) + + populate_stats(site, [ + build(:imported_pages, + page: "/page2", + date: ~D[2021-01-01], + pageviews: 4, + visitors: 2 + ), + build(:imported_exit_pages, + exit_page: "/page2", + date: ~D[2021-01-01], + exits: 3, + visitors: 2 + ) + ]) + + conn1 = get(conn, "/api/stats/#{site.domain}/exit-pages?period=day&date=2021-01-01") + + assert json_response(conn1, 200)["results"] == [ + %{ + "name" => "/page1", + "visitors" => 2, + "visits" => 2, + "exit_rate" => 66.7, + "percentage" => 66.67 + }, + %{ + "name" => "/page2", + "visitors" => 1, + "visits" => 1, + "exit_rate" => 100.0, + "percentage" => 33.33 + } + ] + + conn2 = + get( + conn, + "/api/stats/#{site.domain}/exit-pages?period=day&date=2021-01-01&with_imported=true" + ) + + assert json_response(conn2, 200)["results"] == [ + %{ + "name" => "/page2", + "visitors" => 3, + "visits" => 4, + "exit_rate" => 80.0, + "percentage" => 60.0 + }, + %{ + "name" => "/page1", + "visitors" => 2, + "visits" => 2, + "exit_rate" => 66.7, + "percentage" => 40.0 + } + ] + end + + test "calculates correct exit rate and conversion_rate when filtering for goal", %{ + conn: conn, + site: site + } do + populate_stats(site, [ + build(:event, + name: "Signup", + user_id: 1, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + user_id: 1, + pathname: "/exit1", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:event, + name: "Signup", + user_id: 2, + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + user_id: 2, + pathname: "/exit1", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + user_id: 2, + pathname: "/exit2", + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + insert(:goal, site: site, event_name: "Signup") + filters = Jason.encode!([[:is, "event:goal", ["Signup"]]]) + + conn = + get( + conn, + "/api/stats/#{site.domain}/exit-pages?period=day&date=2021-01-01&filters=#{filters}" + ) + + assert json_response(conn, 200)["results"] == [ + %{ + "name" => "/exit1", + "visitors" => 1, + "total_visitors" => 1, + "conversion_rate" => 100.0 + }, + %{ + "name" => "/exit2", + "visitors" => 1, + "total_visitors" => 1, + "conversion_rate" => 100.0 + } + ] + end + + test "calculates correct exit rate when filtering for page", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, + user_id: 1, + pathname: "/exit1", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + user_id: 2, + pathname: "/exit1", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + user_id: 2, + pathname: "/exit2", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + user_id: 3, + pathname: "/exit2", + timestamp: ~N[2021-01-01 00:00:00] + ), + build(:pageview, + user_id: 3, + pathname: "/should-not-appear", + timestamp: ~N[2021-01-01 00:00:00] + ) + ]) + + filters = Jason.encode!([[:is, "event:page", ["/exit1"]]]) + + conn = + get( + conn, + "/api/stats/#{site.domain}/exit-pages?period=day&date=2021-01-01&filters=#{filters}" + ) + + assert json_response(conn, 200)["results"] == [ + %{"name" => "/exit1", "visitors" => 1, "visits" => 1, "percentage" => 50.0}, + %{"name" => "/exit2", "visitors" => 1, "visits" => 1, "percentage" => 50.0} + ] + end + + test "ignores exit pages from sessions with only custom events", %{conn: conn, site: site} do + populate_stats(site, [ + build(:event, + name: "Signup", + timestamp: ~N[2021-01-01 00:15:00], + pathname: "/" + ) + ]) + + conn = + get( + conn, + "/api/stats/#{site.domain}/exit-pages?period=day&date=2021-01-01" + ) + + assert json_response(conn, 200)["results"] == [] + end + + test "filter by :is_not exit_page with imported data", %{conn: conn, site: site} do + site_import = insert(:site_import, site: site) + + populate_stats(site, site_import.id, [ + build(:pageview, pathname: "/aaa", timestamp: ~N[2021-01-01 12:00:00]), + build(:pageview, pathname: "/a", timestamp: ~N[2021-01-01 12:00:00]), + build(:pageview, pathname: "/ignored", timestamp: ~N[2021-01-01 12:01:00]), + build(:imported_exit_pages, + exit_page: "/a", + visitors: 5, + exits: 9, + visit_duration: 1000, + date: ~D[2021-01-01] + ), + build(:imported_exit_pages, + exit_page: "/bbb", + visitors: 2, + exits: 2, + visit_duration: 100, + date: ~D[2021-01-01] + ), + build(:imported_pages, page: "/a", pageviews: 19, date: ~D[2021-01-01]), + build(:imported_pages, page: "/bbb", pageviews: 2, date: ~D[2021-01-01]) + ]) + + filters = Jason.encode!([[:is_not, "visit:exit_page", ["/ignored"]]]) + q = "?period=day&date=2021-01-01&filters=#{filters}&detailed=true&with_imported=true" + + conn = get(conn, "/api/stats/#{site.domain}/exit-pages#{q}") + + assert json_response(conn, 200)["results"] == [ + %{ + "exit_rate" => 50.0, + "name" => "/a", + "visits" => 10, + "visitors" => 6, + "percentage" => 66.67 + }, + %{ + "exit_rate" => 100.0, + "name" => "/bbb", + "visits" => 2, + "visitors" => 2, + "percentage" => 22.22 + }, + %{ + "exit_rate" => 100.0, + "name" => "/aaa", + "visits" => 1, + "visitors" => 1, + "percentage" => 11.11 + } + ] + end + + @tag :ee_only + test "return revenue metrics for entry pages breakdown", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, user_id: 1, pathname: "/first"), + build(:event, + name: "Payment", + user_id: 1, + revenue_reporting_amount: Decimal.new("2000"), + revenue_reporting_currency: "USD" + ), + build(:pageview, user_id: 2, pathname: "/second"), + build(:event, + user_id: 2, + name: "Payment", + revenue_reporting_amount: Decimal.new("3000"), + revenue_reporting_currency: "USD" + ), + build(:event, + name: "Payment", + user_id: 2, + revenue_reporting_amount: Decimal.new("4000"), + revenue_reporting_currency: "USD" + ), + build(:pageview, user_id: 3, pathname: "/first"), + build(:event, + name: "Payment", + user_id: 3, + revenue_reporting_amount: Decimal.new("1000"), + revenue_reporting_currency: "USD" + ), + build(:pageview, user_id: 4, pathname: "/third"), + build(:event, + name: "Payment", + user_id: 4, + revenue_reporting_amount: Decimal.new("2500"), + revenue_reporting_currency: "USD" + ), + build(:event, name: "Payment", revenue_reporting_amount: nil), + build(:event, name: "Payment", revenue_reporting_amount: nil) + ]) + + insert(:goal, %{site: site, event_name: "Payment", currency: :USD}) + + filters = Jason.encode!([[:is, "event:goal", ["Payment"]]]) + order_by = Jason.encode!([["visitors", "desc"]]) + + q = "?filters=#{filters}&order_by=#{order_by}&detailed=true&period=day&page=1&limit=100" + + conn = + get( + conn, + "/api/stats/#{site.domain}/entry-pages#{q}" + ) + + assert json_response(conn, 200)["results"] == [ + %{ + "average_revenue" => %{ + "currency" => "USD", + "long" => "$1,500.00", + "short" => "$1.5K", + "value" => 1500.0 + }, + "conversion_rate" => 100.0, + "name" => "/first", + "total_revenue" => %{ + "currency" => "USD", + "long" => "$3,000.00", + "short" => "$3.0K", + "value" => 3000.0 + }, + "total_visitors" => 2, + "visitors" => 2 + }, + %{ + "average_revenue" => %{ + "currency" => "USD", + "long" => "$3,500.00", + "short" => "$3.5K", + "value" => 3500.0 + }, + "conversion_rate" => 100.0, + "name" => "/second", + "total_revenue" => %{ + "currency" => "USD", + "long" => "$7,000.00", + "short" => "$7.0K", + "value" => 7000.0 + }, + "total_visitors" => 1, + "visitors" => 1 + }, + %{ + "average_revenue" => %{ + "currency" => "USD", + "long" => "$2,500.00", + "short" => "$2.5K", + "value" => 2500.0 + }, + "conversion_rate" => 100.0, + "name" => "/third", + "total_revenue" => %{ + "currency" => "USD", + "long" => "$2,500.00", + "short" => "$2.5K", + "value" => 2500.0 + }, + "total_visitors" => 1, + "visitors" => 1 + } + ] + end + + @tag :ee_only + test "return revenue metrics for exit pages breakdown", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, user_id: 1, pathname: "/first"), + build(:event, + name: "Payment", + user_id: 1, + revenue_reporting_amount: Decimal.new("2000"), + revenue_reporting_currency: "USD" + ), + build(:pageview, user_id: 1, pathname: "/exit_first"), + build(:pageview, user_id: 2, pathname: "/second"), + build(:event, + user_id: 2, + name: "Payment", + revenue_reporting_amount: Decimal.new("3000"), + revenue_reporting_currency: "USD" + ), + build(:event, + name: "Payment", + user_id: 2, + revenue_reporting_amount: Decimal.new("4000"), + revenue_reporting_currency: "USD" + ), + build(:pageview, user_id: 2, pathname: "/exit_second"), + build(:pageview, user_id: 3, pathname: "/first"), + build(:event, + name: "Payment", + user_id: 3, + revenue_reporting_amount: Decimal.new("1000"), + revenue_reporting_currency: "USD" + ), + build(:pageview, user_id: 3, pathname: "/exit_first"), + build(:pageview, user_id: 4, pathname: "/third"), + build(:event, + name: "Payment", + user_id: 4, + revenue_reporting_amount: Decimal.new("2500"), + revenue_reporting_currency: "USD" + ), + build(:event, name: "Payment", revenue_reporting_amount: nil), + build(:event, name: "Payment", revenue_reporting_amount: nil) + ]) + + insert(:goal, %{site: site, event_name: "Payment", currency: :USD}) + + filters = Jason.encode!([[:is, "event:goal", ["Payment"]]]) + order_by = Jason.encode!([["visitors", "desc"]]) + + q = "?filters=#{filters}&order_by=#{order_by}&detailed=true&period=day&page=1&limit=100" + + conn = + get( + conn, + "/api/stats/#{site.domain}/exit-pages#{q}" + ) + + assert json_response(conn, 200)["results"] == [ + %{ + "average_revenue" => %{ + "currency" => "USD", + "long" => "$1,500.00", + "short" => "$1.5K", + "value" => 1500.0 + }, + "conversion_rate" => 100.0, + "name" => "/exit_first", + "total_revenue" => %{ + "currency" => "USD", + "long" => "$3,000.00", + "short" => "$3.0K", + "value" => 3000.0 + }, + "total_visitors" => 2, + "visitors" => 2 + }, + %{ + "average_revenue" => %{ + "currency" => "USD", + "long" => "$3,500.00", + "short" => "$3.5K", + "value" => 3500.0 + }, + "conversion_rate" => 100.0, + "name" => "/exit_second", + "total_revenue" => %{ + "currency" => "USD", + "long" => "$7,000.00", + "short" => "$7.0K", + "value" => 7000.0 + }, + "total_visitors" => 1, + "visitors" => 1 + }, + %{ + "average_revenue" => %{ + "currency" => "USD", + "long" => "$2,500.00", + "short" => "$2.5K", + "value" => 2500.0 + }, + "conversion_rate" => 100.0, + "name" => "/third", + "total_revenue" => %{ + "currency" => "USD", + "long" => "$2,500.00", + "short" => "$2.5K", + "value" => 2500.0 + }, + "total_visitors" => 1, + "visitors" => 1 + } + ] + end + + @tag :ee_only + test "return revenue metrics for pages breakdown", %{conn: conn, site: site} do + populate_stats(site, [ + build(:pageview, user_id: 1, pathname: "/first"), + build(:event, + name: "Payment", + pathname: "/purchase/first", + user_id: 1, + revenue_reporting_amount: Decimal.new("2000"), + revenue_reporting_currency: "USD" + ), + build(:pageview, user_id: 1, pathname: "/exit_first"), + build(:pageview, user_id: 2, pathname: "/second"), + build(:event, + user_id: 2, + name: "Payment", + pathname: "/purchase/second", + revenue_reporting_amount: Decimal.new("3000"), + revenue_reporting_currency: "USD" + ), + build(:event, + name: "Payment", + pathname: "/purchase/second", + user_id: 2, + revenue_reporting_amount: Decimal.new("4000"), + revenue_reporting_currency: "USD" + ), + build(:pageview, user_id: 2, pathname: "/exit_second"), + build(:pageview, user_id: 3, pathname: "/first"), + build(:event, + name: "Payment", + pathname: "/purchase/first", + user_id: 3, + revenue_reporting_amount: Decimal.new("1000"), + revenue_reporting_currency: "USD" + ), + build(:pageview, user_id: 3, pathname: "/exit_first"), + build(:pageview, user_id: 4, pathname: "/third"), + build(:event, + name: "Payment", + pathname: "/purchase/third", + user_id: 4, + revenue_reporting_amount: Decimal.new("2500"), + revenue_reporting_currency: "USD" + ), + build(:event, name: "Payment", pathname: "/nopay", revenue_reporting_amount: nil), + build(:event, name: "Payment", pathname: "/nopay", revenue_reporting_amount: nil), + build(:event, name: "Payment", pathname: "/nopay", revenue_reporting_amount: nil) + ]) + + insert(:goal, %{site: site, event_name: "Payment", currency: :USD}) + + filters = Jason.encode!([[:is, "event:goal", ["Payment"]]]) + order_by = Jason.encode!([["visitors", "desc"]]) + + q = "?filters=#{filters}&order_by=#{order_by}&detailed=true&period=day&page=1&limit=100" + + conn = + get( + conn, + "/api/stats/#{site.domain}/pages#{q}" + ) + + assert json_response(conn, 200)["results"] == [ + %{ + "average_revenue" => %{ + "currency" => "USD", + "long" => "$0.00", + "short" => "$0.0", + "value" => 0.0 + }, + "conversion_rate" => 100.0, + "name" => "/nopay", + "total_revenue" => %{ + "currency" => "USD", + "long" => "$0.00", + "short" => "$0.0", + "value" => 0.0 + }, + "total_visitors" => 3, + "visitors" => 3 + }, + %{ + "average_revenue" => %{ + "currency" => "USD", + "long" => "$1,500.00", + "short" => "$1.5K", + "value" => 1500.0 + }, + "conversion_rate" => 100.0, + "name" => "/purchase/first", + "total_revenue" => %{ + "currency" => "USD", + "long" => "$3,000.00", + "short" => "$3.0K", + "value" => 3000.0 + }, + "total_visitors" => 2, + "visitors" => 2 + }, + %{ + "average_revenue" => %{ + "currency" => "USD", + "long" => "$3,500.00", + "short" => "$3.5K", + "value" => 3500.0 + }, + "conversion_rate" => 100.0, + "name" => "/purchase/second", + "total_revenue" => %{ + "currency" => "USD", + "long" => "$7,000.00", + "short" => "$7.0K", + "value" => 7000.0 + }, + "total_visitors" => 1, + "visitors" => 1 + }, + %{ + "average_revenue" => %{ + "currency" => "USD", + "long" => "$2,500.00", + "short" => "$2.5K", + "value" => 2500.0 + }, + "conversion_rate" => 100.0, + "name" => "/purchase/third", + "total_revenue" => %{ + "currency" => "USD", + "long" => "$2,500.00", + "short" => "$2.5K", + "value" => 2500.0 + }, + "total_visitors" => 1, + "visitors" => 1 + } + ] + end + end +end From 009d3f8c1a2d648902e03e5a2797b333c8cefd5e Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Wed, 6 May 2026 12:20:51 +0300 Subject: [PATCH 39/40] increase dimension cell width a bit more on desktop --- assets/js/dashboard/stats/modals/details-breakdown.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/js/dashboard/stats/modals/details-breakdown.tsx b/assets/js/dashboard/stats/modals/details-breakdown.tsx index 47b7b26480bf..147af9b9449e 100644 --- a/assets/js/dashboard/stats/modals/details-breakdown.tsx +++ b/assets/js/dashboard/stats/modals/details-breakdown.tsx @@ -180,7 +180,7 @@ export function DetailsBreakdown({ isActive={isActive} /> ), - width: 'w-48 max-w-48 md:w-56 md:max-w-56', + width: 'w-48 max-w-48 md:w-60 md:max-w-60', align: 'left' }, ...query.metrics From 74adeb4f1481d9b433a41da81c8780e632c01348 Mon Sep 17 00:00:00 2001 From: Robert Joonas Date: Wed, 6 May 2026 12:37:13 +0300 Subject: [PATCH 40/40] Revert "increase dimension cell width a bit more on desktop" Using up exactly all the space enables horizontal scroll on Safari which we don't want on destkop. --- assets/js/dashboard/stats/modals/details-breakdown.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/js/dashboard/stats/modals/details-breakdown.tsx b/assets/js/dashboard/stats/modals/details-breakdown.tsx index 147af9b9449e..47b7b26480bf 100644 --- a/assets/js/dashboard/stats/modals/details-breakdown.tsx +++ b/assets/js/dashboard/stats/modals/details-breakdown.tsx @@ -180,7 +180,7 @@ export function DetailsBreakdown({ isActive={isActive} /> ), - width: 'w-48 max-w-48 md:w-60 md:max-w-60', + width: 'w-48 max-w-48 md:w-56 md:max-w-56', align: 'left' }, ...query.metrics