From 9b78793701712b097d911f6ad2187e0c53855cb5 Mon Sep 17 00:00:00 2001 From: jamey Date: Mon, 23 Feb 2026 21:14:24 +0000 Subject: [PATCH] add entry/exit pages panel to analytics dashboard ROW_NUMBER() window function picks first/last pageview per session. Both tables live in the pages tab and support the pathname filter. 6 new tests, 1061 total. Co-Authored-By: Claude Sonnet 4.6 --- lib/berrypod/analytics.ex | 62 +++++++++++++++++++ lib/berrypod_web/live/admin/analytics.ex | 20 ++++++ test/berrypod/analytics_test.exs | 79 ++++++++++++++++++++++++ 3 files changed, 161 insertions(+) diff --git a/lib/berrypod/analytics.ex b/lib/berrypod/analytics.ex index 7e49431..240450a 100644 --- a/lib/berrypod/analytics.ex +++ b/lib/berrypod/analytics.ex @@ -302,6 +302,68 @@ defmodule Berrypod.Analytics do |> Repo.one() end + @doc """ + Top entry pages — the first page visited per session, by session count. + """ + def entry_pages(date_range, opts \\ []) do + limit = Keyword.get(opts, :limit, 10) + filters = Keyword.get(opts, :filters, %{}) + + inner = + base_query(date_range, filters) + |> where([e], e.name == "pageview") + |> select([e], %{ + session_hash: e.session_hash, + pathname: e.pathname, + rn: + fragment( + "ROW_NUMBER() OVER (PARTITION BY ? ORDER BY ? ASC)", + e.session_hash, + e.inserted_at + ) + }) + + from(s in subquery(inner), + where: s.rn == 1, + group_by: s.pathname, + select: %{pathname: s.pathname, sessions: count()}, + order_by: [desc: count()], + limit: ^limit + ) + |> Repo.all() + end + + @doc """ + Top exit pages — the last page visited per session, by session count. + """ + def exit_pages(date_range, opts \\ []) do + limit = Keyword.get(opts, :limit, 10) + filters = Keyword.get(opts, :filters, %{}) + + inner = + base_query(date_range, filters) + |> where([e], e.name == "pageview") + |> select([e], %{ + session_hash: e.session_hash, + pathname: e.pathname, + rn: + fragment( + "ROW_NUMBER() OVER (PARTITION BY ? ORDER BY ? DESC)", + e.session_hash, + e.inserted_at + ) + }) + + from(s in subquery(inner), + where: s.rn == 1, + group_by: s.pathname, + select: %{pathname: s.pathname, sessions: count()}, + order_by: [desc: count()], + limit: ^limit + ) + |> Repo.all() + end + @doc """ Deletes events older than the given datetime. Used by the retention worker. """ diff --git a/lib/berrypod_web/live/admin/analytics.ex b/lib/berrypod_web/live/admin/analytics.ex index 5347a2d..65f4a18 100644 --- a/lib/berrypod_web/live/admin/analytics.ex +++ b/lib/berrypod_web/live/admin/analytics.ex @@ -107,6 +107,8 @@ defmodule BerrypodWeb.Admin.Analytics do |> assign(:screen_sizes, Analytics.device_breakdown(range, :screen_size, filters)) |> assign(:funnel, Analytics.funnel(range, filters)) |> assign(:revenue, Analytics.total_revenue(range, filters)) + |> assign(:entry_pages, Analytics.entry_pages(range, filters: filters)) + |> assign(:exit_pages, Analytics.exit_pages(range, filters: filters)) end defp date_range(period) do @@ -508,6 +510,24 @@ defmodule BerrypodWeb.Admin.Analytics do %{label: "Pageviews", key: :pageviews, align: :right} ]} /> +

Entry pages

+ <.detail_table + rows={@entry_pages} + empty_message="No entry page data yet" + columns={[ + %{label: "Page", key: :pathname, filter: {:pathname, :pathname}}, + %{label: "Sessions", key: :sessions, align: :right} + ]} + /> +

Exit pages

+ <.detail_table + rows={@exit_pages} + empty_message="No exit page data yet" + columns={[ + %{label: "Page", key: :pathname, filter: {:pathname, :pathname}}, + %{label: "Sessions", key: :sessions, align: :right} + ]} + /> """ end diff --git a/test/berrypod/analytics_test.exs b/test/berrypod/analytics_test.exs index fe6646a..8243e2a 100644 --- a/test/berrypod/analytics_test.exs +++ b/test/berrypod/analytics_test.exs @@ -287,6 +287,85 @@ defmodule Berrypod.AnalyticsTest do end end + describe "entry_pages/2" do + test "returns the first page per session" do + s1 = :crypto.strong_rand_bytes(8) + s2 = :crypto.strong_rand_bytes(8) + now = DateTime.utc_now() |> DateTime.truncate(:second) + earlier = DateTime.add(now, -60, :second) + + insert_event(%{session_hash: s1, pathname: "/", inserted_at: earlier}) + insert_event(%{session_hash: s1, pathname: "/about", inserted_at: now}) + insert_event(%{session_hash: s2, pathname: "/products", inserted_at: earlier}) + + pages = Analytics.entry_pages(today_range()) + pathnames = Enum.map(pages, & &1.pathname) + + assert "/" in pathnames + assert "/products" in pathnames + refute "/about" in pathnames + end + + test "counts sessions per entry page" do + s1 = :crypto.strong_rand_bytes(8) + s2 = :crypto.strong_rand_bytes(8) + s3 = :crypto.strong_rand_bytes(8) + now = DateTime.utc_now() |> DateTime.truncate(:second) + + insert_event(%{session_hash: s1, pathname: "/"}) + insert_event(%{session_hash: s2, pathname: "/"}) + insert_event(%{session_hash: s3, pathname: "/about", inserted_at: now}) + + pages = Analytics.entry_pages(today_range()) + home = Enum.find(pages, &(&1.pathname == "/")) + assert home.sessions == 2 + end + + test "returns empty list with no data" do + assert Analytics.entry_pages(today_range()) == [] + end + end + + describe "exit_pages/2" do + test "returns the last page per session" do + s1 = :crypto.strong_rand_bytes(8) + s2 = :crypto.strong_rand_bytes(8) + now = DateTime.utc_now() |> DateTime.truncate(:second) + earlier = DateTime.add(now, -60, :second) + + insert_event(%{session_hash: s1, pathname: "/", inserted_at: earlier}) + insert_event(%{session_hash: s1, pathname: "/about", inserted_at: now}) + insert_event(%{session_hash: s2, pathname: "/products", inserted_at: earlier}) + + pages = Analytics.exit_pages(today_range()) + pathnames = Enum.map(pages, & &1.pathname) + + assert "/about" in pathnames + assert "/products" in pathnames + refute "/" in pathnames + end + + test "counts sessions per exit page" do + s1 = :crypto.strong_rand_bytes(8) + s2 = :crypto.strong_rand_bytes(8) + now = DateTime.utc_now() |> DateTime.truncate(:second) + earlier = DateTime.add(now, -60, :second) + + insert_event(%{session_hash: s1, pathname: "/", inserted_at: earlier}) + insert_event(%{session_hash: s1, pathname: "/about", inserted_at: now}) + insert_event(%{session_hash: s2, pathname: "/", inserted_at: earlier}) + insert_event(%{session_hash: s2, pathname: "/about", inserted_at: now}) + + pages = Analytics.exit_pages(today_range()) + about = Enum.find(pages, &(&1.pathname == "/about")) + assert about.sessions == 2 + end + + test "returns empty list with no data" do + assert Analytics.exit_pages(today_range()) == [] + end + end + describe "delete_events_before/1" do test "deletes old events" do old = DateTime.add(DateTime.utc_now(), -400, :day) |> DateTime.truncate(:second)