add dashboard filtering to analytics
All checks were successful
deploy / deploy (push) Successful in 1m19s
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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user