add entry/exit pages panel to analytics dashboard
All checks were successful
deploy / deploy (push) Successful in 1m28s
All checks were successful
deploy / deploy (push) Successful in 1m28s
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 <noreply@anthropic.com>
This commit is contained in:
parent
162a5bfe9a
commit
9b78793701
@ -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.
|
||||
"""
|
||||
|
||||
@ -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}
|
||||
]}
|
||||
/>
|
||||
<h3 style="font-size: 0.875rem; font-weight: 600; margin: 1.5rem 0 0.75rem;">Entry pages</h3>
|
||||
<.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}
|
||||
]}
|
||||
/>
|
||||
<h3 style="font-size: 0.875rem; font-weight: 600; margin: 1.5rem 0 0.75rem;">Exit pages</h3>
|
||||
<.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
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user