add dashboard filtering to analytics
All checks were successful
deploy / deploy (push) Successful in 1m19s

Click any row in pages, sources, countries, or devices tables to
filter the entire dashboard by that dimension. Active filters show
as dismissible chips. Filters thread through all queries including
previous-period deltas. 1050 tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jamey
2026-02-23 13:46:34 +00:00
parent 6eda1de1bc
commit 7ceee9c814
6 changed files with 328 additions and 61 deletions

View File

@@ -56,8 +56,8 @@ defmodule Berrypod.Analytics do
@doc """
Counts unique visitors in the given date range.
"""
def count_visitors(date_range) do
base_query(date_range)
def count_visitors(date_range, filters \\ %{}) do
base_query(date_range, filters)
|> where([e], e.name == "pageview")
|> select([e], count(e.visitor_hash, :distinct))
|> Repo.one()
@@ -66,8 +66,8 @@ defmodule Berrypod.Analytics do
@doc """
Counts total pageviews in the given date range.
"""
def count_pageviews(date_range) do
base_query(date_range)
def count_pageviews(date_range, filters \\ %{}) do
base_query(date_range, filters)
|> where([e], e.name == "pageview")
|> select([e], count())
|> Repo.one()
@@ -78,9 +78,9 @@ defmodule Berrypod.Analytics do
Bounce = a session with only one pageview.
"""
def bounce_rate(date_range) do
def bounce_rate(date_range, filters \\ %{}) do
sessions_query =
base_query(date_range)
base_query(date_range, filters)
|> where([e], e.name == "pageview")
|> group_by([e], e.session_hash)
|> select([e], %{
@@ -107,9 +107,9 @@ defmodule Berrypod.Analytics do
@doc """
Average visit duration in seconds.
"""
def avg_duration(date_range) do
def avg_duration(date_range, filters \\ %{}) do
durations_query =
base_query(date_range)
base_query(date_range, filters)
|> where([e], e.name == "pageview")
|> group_by([e], e.session_hash)
|> having([e], count() > 1)
@@ -136,8 +136,8 @@ defmodule Berrypod.Analytics do
Returns a list of `%{date: ~D[], visitors: integer}` maps.
"""
def visitors_by_date(date_range) do
base_query(date_range)
def visitors_by_date(date_range, filters \\ %{}) do
base_query(date_range, filters)
|> where([e], e.name == "pageview")
|> group_by([e], fragment("date(?)", e.inserted_at))
|> select([e], %{
@@ -153,9 +153,9 @@ defmodule Berrypod.Analytics do
Returns a list of `%{hour: integer, visitors: integer}` maps for all 24 hours.
"""
def visitors_by_hour(date_range) do
def visitors_by_hour(date_range, filters \\ %{}) do
counts =
base_query(date_range)
base_query(date_range, filters)
|> where([e], e.name == "pageview")
|> group_by([e], fragment("CAST(strftime('%H', ?) AS INTEGER)", e.inserted_at))
|> select([e], %{
@@ -174,8 +174,11 @@ defmodule Berrypod.Analytics do
@doc """
Top pages by unique visitors.
"""
def top_pages(date_range, limit \\ 10) do
base_query(date_range)
def top_pages(date_range, opts \\ []) do
limit = Keyword.get(opts, :limit, 10)
filters = Keyword.get(opts, :filters, %{})
base_query(date_range, filters)
|> where([e], e.name == "pageview")
|> group_by([e], e.pathname)
|> select([e], %{
@@ -191,8 +194,11 @@ defmodule Berrypod.Analytics do
@doc """
Top referrer sources by unique visitors.
"""
def top_sources(date_range, limit \\ 10) do
base_query(date_range)
def top_sources(date_range, opts \\ []) do
limit = Keyword.get(opts, :limit, 10)
filters = Keyword.get(opts, :filters, %{})
base_query(date_range, filters)
|> where([e], e.name == "pageview")
|> where([e], not is_nil(e.referrer_source))
|> group_by([e], e.referrer_source)
@@ -208,8 +214,11 @@ defmodule Berrypod.Analytics do
@doc """
Top referrer domains by unique visitors.
"""
def top_referrers(date_range, limit \\ 10) do
base_query(date_range)
def top_referrers(date_range, opts \\ []) do
limit = Keyword.get(opts, :limit, 10)
filters = Keyword.get(opts, :filters, %{})
base_query(date_range, filters)
|> where([e], e.name == "pageview")
|> where([e], not is_nil(e.referrer))
|> group_by([e], e.referrer)
@@ -225,8 +234,11 @@ defmodule Berrypod.Analytics do
@doc """
Country breakdown by unique visitors.
"""
def top_countries(date_range, limit \\ 10) do
base_query(date_range)
def top_countries(date_range, opts \\ []) do
limit = Keyword.get(opts, :limit, 10)
filters = Keyword.get(opts, :filters, %{})
base_query(date_range, filters)
|> where([e], e.name == "pageview")
|> where([e], not is_nil(e.country_code))
|> group_by([e], e.country_code)
@@ -242,10 +254,11 @@ defmodule Berrypod.Analytics do
@doc """
Device breakdown by the given dimension (:browser, :os, or :screen_size).
"""
def device_breakdown(date_range, dimension) when dimension in [:browser, :os, :screen_size] do
def device_breakdown(date_range, dimension, filters \\ %{})
when dimension in [:browser, :os, :screen_size] do
field = dimension
base_query(date_range)
base_query(date_range, filters)
|> where([e], e.name == "pageview")
|> where([e], not is_nil(field(e, ^field)))
|> group_by([e], field(e, ^field))
@@ -262,9 +275,9 @@ defmodule Berrypod.Analytics do
Returns `%{product_views: n, add_to_carts: n, checkouts: n, purchases: n}`.
"""
def funnel(date_range) do
def funnel(date_range, filters \\ %{}) do
counts =
base_query(date_range)
base_query(date_range, filters)
|> where([e], e.name in ["product_view", "add_to_cart", "checkout_start", "purchase"])
|> group_by([e], e.name)
|> select([e], {e.name, count(e.visitor_hash, :distinct)})
@@ -282,8 +295,8 @@ defmodule Berrypod.Analytics do
@doc """
Total revenue in the given date range (pence).
"""
def total_revenue(date_range) do
base_query(date_range)
def total_revenue(date_range, filters \\ %{}) do
base_query(date_range, filters)
|> where([e], e.name == "purchase")
|> select([e], coalesce(sum(e.revenue), 0))
|> Repo.one()
@@ -299,9 +312,24 @@ defmodule Berrypod.Analytics do
# ── Private ──
defp base_query({start_date, end_date}) do
defp base_query({start_date, end_date}, filters) do
from(e in Event,
where: e.inserted_at >= ^start_date and e.inserted_at < ^end_date
)
|> apply_filters(filters)
end
defp apply_filters(query, filters) when map_size(filters) == 0, do: query
defp apply_filters(query, filters) do
Enum.reduce(filters, query, fn
{:pathname, v}, q -> where(q, [e], e.pathname == ^v)
{:referrer_source, v}, q -> where(q, [e], e.referrer_source == ^v)
{:country_code, v}, q -> where(q, [e], e.country_code == ^v)
{:browser, v}, q -> where(q, [e], e.browser == ^v)
{:os, v}, q -> where(q, [e], e.os == ^v)
{:screen_size, v}, q -> where(q, [e], e.screen_size == ^v)
_, q -> q
end)
end
end

View File

@@ -11,6 +11,8 @@ defmodule BerrypodWeb.Admin.Analytics do
"12m" => 364
}
@filterable_dimensions ~w(pathname referrer_source country_code browser os screen_size)
@impl true
def mount(_params, _session, socket) do
{:ok,
@@ -18,6 +20,7 @@ defmodule BerrypodWeb.Admin.Analytics do
|> assign(:page_title, "Analytics")
|> assign(:period, "30d")
|> assign(:tab, "pages")
|> assign(:filters, %{})
|> load_analytics("30d")}
end
@@ -35,26 +38,54 @@ defmodule BerrypodWeb.Admin.Analytics do
{:noreply, assign(socket, :tab, tab)}
end
def handle_event("add_filter", %{"dimension" => dim, "value" => val}, socket)
when dim in @filterable_dimensions do
filters = Map.put(socket.assigns.filters, String.to_existing_atom(dim), val)
{:noreply,
socket
|> assign(:filters, filters)
|> load_analytics(socket.assigns.period)}
end
def handle_event("remove_filter", %{"dimension" => dim}, socket)
when dim in @filterable_dimensions do
filters = Map.delete(socket.assigns.filters, String.to_existing_atom(dim))
{:noreply,
socket
|> assign(:filters, filters)
|> load_analytics(socket.assigns.period)}
end
def handle_event("clear_filters", _params, socket) do
{:noreply,
socket
|> assign(:filters, %{})
|> load_analytics(socket.assigns.period)}
end
# ── Data loading ──
defp load_analytics(socket, period) do
range = date_range(period)
prev_range = previous_date_range(period)
filters = socket.assigns.filters
trend_data =
if period == "today",
do: Analytics.visitors_by_hour(range),
else: Analytics.visitors_by_date(range)
do: Analytics.visitors_by_hour(range, filters),
else: Analytics.visitors_by_date(range, filters)
visitors = Analytics.count_visitors(range)
pageviews = Analytics.count_pageviews(range)
bounce_rate = Analytics.bounce_rate(range)
avg_duration = Analytics.avg_duration(range)
visitors = Analytics.count_visitors(range, filters)
pageviews = Analytics.count_pageviews(range, filters)
bounce_rate = Analytics.bounce_rate(range, filters)
avg_duration = Analytics.avg_duration(range, filters)
prev_visitors = Analytics.count_visitors(prev_range)
prev_pageviews = Analytics.count_pageviews(prev_range)
prev_bounce_rate = Analytics.bounce_rate(prev_range)
prev_avg_duration = Analytics.avg_duration(prev_range)
prev_visitors = Analytics.count_visitors(prev_range, filters)
prev_pageviews = Analytics.count_pageviews(prev_range, filters)
prev_bounce_rate = Analytics.bounce_rate(prev_range, filters)
prev_avg_duration = Analytics.avg_duration(prev_range, filters)
socket
|> assign(:visitors, visitors)
@@ -67,15 +98,15 @@ defmodule BerrypodWeb.Admin.Analytics do
|> assign(:avg_duration_delta, compute_delta(avg_duration, prev_avg_duration))
|> assign(:trend_data, trend_data)
|> assign(:trend_mode, if(period == "today", do: :hourly, else: :daily))
|> assign(:top_pages, Analytics.top_pages(range))
|> assign(:top_sources, Analytics.top_sources(range))
|> assign(:top_referrers, Analytics.top_referrers(range))
|> assign(:top_countries, Analytics.top_countries(range))
|> assign(:browsers, Analytics.device_breakdown(range, :browser))
|> assign(:oses, Analytics.device_breakdown(range, :os))
|> assign(:screen_sizes, Analytics.device_breakdown(range, :screen_size))
|> assign(:funnel, Analytics.funnel(range))
|> assign(:revenue, Analytics.total_revenue(range))
|> assign(:top_pages, Analytics.top_pages(range, filters: filters))
|> assign(:top_sources, Analytics.top_sources(range, filters: filters))
|> assign(:top_referrers, Analytics.top_referrers(range, filters: filters))
|> assign(:top_countries, Analytics.top_countries(range, filters: filters))
|> assign(:browsers, Analytics.device_breakdown(range, :browser, filters))
|> assign(:oses, Analytics.device_breakdown(range, :os, filters))
|> assign(:screen_sizes, Analytics.device_breakdown(range, :screen_size, filters))
|> assign(:funnel, Analytics.funnel(range, filters))
|> assign(:revenue, Analytics.total_revenue(range, filters))
end
defp date_range(period) do
@@ -122,6 +153,27 @@ defmodule BerrypodWeb.Admin.Analytics do
</button>
</div>
<%!-- Active filters --%>
<div
:if={@filters != %{}}
id="analytics-filters"
style="display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center; margin-top: 1rem;"
>
<.filter_chip
:for={{dim, val} <- @filters}
dimension={dim}
value={val}
/>
<button
:if={map_size(@filters) > 1}
phx-click="clear_filters"
class="admin-btn admin-btn-sm"
style="font-size: 0.75rem;"
>
Clear all
</button>
</div>
<%!-- Stat cards --%>
<div class="admin-stats-grid" style="margin-top: 1.5rem;">
<.stat_card
@@ -245,6 +297,36 @@ defmodule BerrypodWeb.Admin.Analytics do
"""
end
# ── Filter chip ──
attr :dimension, :atom, required: true
attr :value, :string, required: true
defp filter_chip(assigns) do
assigns = assign(assigns, label: filter_label(assigns.dimension, assigns.value))
~H"""
<span style="display: inline-flex; align-items: center; gap: 0.25rem; padding: 0.25rem 0.5rem; font-size: 0.75rem; background: var(--color-base-200, #e5e5e5); border-radius: 0.25rem;">
{@label}
<button
phx-click="remove_filter"
phx-value-dimension={@dimension}
style="cursor: pointer; opacity: 0.6; line-height: 1;"
aria-label={"Remove #{@label} filter"}
>
<.icon name="hero-x-mark" class="size-3" />
</button>
</span>
"""
end
defp filter_label(:pathname, val), do: "Page: #{val}"
defp filter_label(:referrer_source, val), do: "Source: #{val}"
defp filter_label(:country_code, val), do: "Country: #{country_name(val)}"
defp filter_label(:browser, val), do: "Browser: #{val}"
defp filter_label(:os, val), do: "OS: #{val}"
defp filter_label(:screen_size, val), do: "Screen: #{val}"
# ── Bar chart (HTML/CSS bars with readable labels) ──
attr :data, :list, required: true
@@ -421,7 +503,7 @@ defmodule BerrypodWeb.Admin.Analytics do
rows={@top_pages}
empty_message="No page data yet"
columns={[
%{label: "Page", key: :pathname},
%{label: "Page", key: :pathname, filter: {:pathname, :pathname}},
%{label: "Visitors", key: :visitors, align: :right},
%{label: "Pageviews", key: :pageviews, align: :right}
]}
@@ -436,7 +518,7 @@ defmodule BerrypodWeb.Admin.Analytics do
rows={@top_sources}
empty_message="No referrer data yet"
columns={[
%{label: "Source", key: :source},
%{label: "Source", key: :source, filter: {:referrer_source, :source}},
%{label: "Visitors", key: :visitors, align: :right}
]}
/>
@@ -453,13 +535,20 @@ defmodule BerrypodWeb.Admin.Analytics do
end
defp tab_content(%{tab: "countries"} = assigns) do
rows =
Enum.map(assigns.top_countries, fn c ->
Map.put(c, :display_name, country_name(c.country_code))
end)
assigns = assign(assigns, :country_rows, rows)
~H"""
<h3 style="font-size: 0.875rem; font-weight: 600; margin-bottom: 0.75rem;">Countries</h3>
<.detail_table
rows={Enum.map(@top_countries, fn c -> %{c | country_code: country_name(c.country_code)} end)}
rows={@country_rows}
empty_message="No country data yet"
columns={[
%{label: "Country", key: :country_code},
%{label: "Country", key: :display_name, filter: {:country_code, :country_code}},
%{label: "Visitors", key: :visitors, align: :right}
]}
/>
@@ -473,7 +562,7 @@ defmodule BerrypodWeb.Admin.Analytics do
rows={@browsers}
empty_message="No browser data yet"
columns={[
%{label: "Browser", key: :name},
%{label: "Browser", key: :name, filter: {:browser, :name}},
%{label: "Visitors", key: :visitors, align: :right}
]}
/>
@@ -484,7 +573,7 @@ defmodule BerrypodWeb.Admin.Analytics do
rows={@oses}
empty_message="No OS data yet"
columns={[
%{label: "OS", key: :name},
%{label: "OS", key: :name, filter: {:os, :name}},
%{label: "Visitors", key: :visitors, align: :right}
]}
/>
@@ -493,7 +582,7 @@ defmodule BerrypodWeb.Admin.Analytics do
rows={@screen_sizes}
empty_message="No screen data yet"
columns={[
%{label: "Size", key: :name},
%{label: "Size", key: :name, filter: {:screen_size, :name}},
%{label: "Visitors", key: :visitors, align: :right}
]}
/>
@@ -540,7 +629,19 @@ defmodule BerrypodWeb.Admin.Analytics do
:for={col <- @columns}
style={col[:align] == :right && "text-align: right; font-variant-numeric: tabular-nums;"}
>
{Map.get(row, col.key)}
<%= if col[:filter] do %>
<span
class="admin-link"
style="cursor: pointer;"
phx-click="add_filter"
phx-value-dimension={elem(col.filter, 0)}
phx-value-value={Map.get(row, elem(col.filter, 1))}
>
{Map.get(row, col.key)}
</span>
<% else %>
{Map.get(row, col.key)}
<% end %>
</td>
</tr>
</tbody>