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