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)