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

@@ -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>