berrypod/lib/berrypod_web/live/admin/analytics.ex
jamey 7ceee9c814
All checks were successful
deploy / deploy (push) Successful in 1m19s
add dashboard filtering to analytics
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>
2026-02-23 13:46:34 +00:00

771 lines
24 KiB
Elixir
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

defmodule BerrypodWeb.Admin.Analytics do
use BerrypodWeb, :live_view
alias Berrypod.Analytics
alias Berrypod.Cart
@periods %{
"today" => 0,
"7d" => 6,
"30d" => 29,
"12m" => 364
}
@filterable_dimensions ~w(pathname referrer_source country_code browser os screen_size)
@impl true
def mount(_params, _session, socket) do
{:ok,
socket
|> assign(:page_title, "Analytics")
|> assign(:period, "30d")
|> assign(:tab, "pages")
|> assign(:filters, %{})
|> load_analytics("30d")}
end
@impl true
def handle_event("change_period", %{"period" => period}, socket)
when is_map_key(@periods, period) do
{:noreply,
socket
|> assign(:period, period)
|> load_analytics(period)}
end
def handle_event("change_tab", %{"tab" => tab}, socket)
when tab in ["pages", "sources", "countries", "devices", "funnel"] 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, filters),
else: Analytics.visitors_by_date(range, filters)
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, 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)
|> assign(:pageviews, pageviews)
|> assign(:bounce_rate, bounce_rate)
|> assign(:avg_duration, avg_duration)
|> assign(:visitors_delta, compute_delta(visitors, prev_visitors))
|> assign(:pageviews_delta, compute_delta(pageviews, prev_pageviews))
|> assign(:bounce_rate_delta, compute_delta(bounce_rate, prev_bounce_rate))
|> 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, 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
days = Map.fetch!(@periods, period)
today = Date.utc_today()
start_date = Date.add(today, -days)
end_date = Date.add(today, 1)
{DateTime.new!(start_date, ~T[00:00:00], "Etc/UTC"),
DateTime.new!(end_date, ~T[00:00:00], "Etc/UTC")}
end
defp previous_date_range(period) do
days = Map.fetch!(@periods, period)
today = Date.utc_today()
duration = days + 1
current_start = Date.add(today, -days)
prev_start = Date.add(current_start, -duration)
{DateTime.new!(prev_start, ~T[00:00:00], "Etc/UTC"),
DateTime.new!(current_start, ~T[00:00:00], "Etc/UTC")}
end
defp compute_delta(0, 0), do: nil
defp compute_delta(_current, 0), do: :new
defp compute_delta(current, previous), do: round((current - previous) / previous * 100)
# ── Render ──
@impl true
def render(assigns) do
~H"""
<.header>Analytics</.header>
<%!-- Period selector --%>
<div class="analytics-periods" style="display: flex; gap: 0.25rem; margin-top: 1rem;">
<button
:for={period <- ["today", "7d", "30d", "12m"]}
phx-click="change_period"
phx-value-period={period}
class={["admin-btn admin-btn-sm", @period == period && "admin-btn-primary"]}
>
{period_label(period)}
</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
label="Unique visitors"
value={format_number(@visitors)}
icon="hero-users"
delta={@visitors_delta}
/>
<.stat_card
label="Total pageviews"
value={format_number(@pageviews)}
icon="hero-eye"
delta={@pageviews_delta}
/>
<.stat_card
label="Bounce rate"
value={"#{@bounce_rate}%"}
icon="hero-arrow-uturn-left"
delta={@bounce_rate_delta}
invert
/>
<.stat_card
label="Visit duration"
value={format_duration(@avg_duration)}
icon="hero-clock"
delta={@avg_duration_delta}
/>
</div>
<%!-- Visitor trend chart --%>
<div class="admin-card" style="margin-top: 1.5rem; padding: 1rem;">
<h3 style="font-size: 0.875rem; font-weight: 600; margin-bottom: 0.75rem;">
Visitors over time
</h3>
<.bar_chart data={@trend_data} mode={@trend_mode} />
</div>
<%!-- Detail tabs --%>
<div style="display: flex; gap: 0.25rem; margin-top: 1.5rem; flex-wrap: wrap;">
<button
:for={
tab <- [
{"pages", "Pages"},
{"sources", "Sources"},
{"countries", "Countries"},
{"devices", "Devices"},
{"funnel", "Funnel"}
]
}
phx-click="change_tab"
phx-value-tab={elem(tab, 0)}
class={["admin-btn admin-btn-sm", @tab == elem(tab, 0) && "admin-btn-primary"]}
>
{elem(tab, 1)}
</button>
</div>
<%!-- Tab content --%>
<div class="admin-card" style="margin-top: 0.75rem; padding: 1rem;">
<.tab_content tab={@tab} {assigns} />
</div>
"""
end
# ── Stat card component ──
attr :label, :string, required: true
attr :value, :any, required: true
attr :icon, :string, required: true
attr :delta, :any, default: nil
attr :invert, :boolean, default: false
defp stat_card(assigns) do
~H"""
<div class="admin-card">
<div style="display: flex; align-items: center; gap: 0.75rem; padding: 1rem;">
<div style="background: var(--color-base-200, #e5e5e5); border-radius: 0.5rem; padding: 0.5rem;">
<.icon name={@icon} class="size-5" />
</div>
<div>
<div style="display: flex; align-items: baseline; gap: 0.5rem;">
<p style="font-size: 1.5rem; font-weight: 700;">{@value}</p>
<.delta_badge :if={@delta != nil} delta={@delta} invert={@invert} />
</div>
<p style="font-size: 0.875rem; color: color-mix(in oklch, var(--color-base-content) 60%, transparent);">
{@label}
</p>
</div>
</div>
</div>
"""
end
attr :delta, :any, required: true
attr :invert, :boolean, required: true
defp delta_badge(%{delta: :new} = assigns) do
~H"""
<span style="font-size: 0.75rem; font-weight: 500; color: color-mix(in oklch, var(--color-base-content) 50%, transparent); white-space: nowrap;">
new
</span>
"""
end
defp delta_badge(assigns) do
{color, arrow} =
cond do
assigns.delta > 0 && !assigns.invert -> {"var(--t-status-success, #22c55e)", ""}
assigns.delta > 0 && assigns.invert -> {"var(--t-status-error, #ef4444)", ""}
assigns.delta < 0 && !assigns.invert -> {"var(--t-status-error, #ef4444)", ""}
assigns.delta < 0 && assigns.invert -> {"var(--t-status-success, #22c55e)", ""}
true -> {"color-mix(in oklch, var(--color-base-content) 40%, transparent)", ""}
end
assigns = assign(assigns, color: color, arrow: arrow)
~H"""
<span style={"font-size: 0.75rem; font-weight: 500; color: #{@color}; white-space: nowrap;"}>
{@arrow} {if abs(@delta) > 999, do: ">999%", else: "#{abs(@delta)}%"}
</span>
"""
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
attr :mode, :atom, required: true
defp bar_chart(assigns) do
data = assigns.data
max_val = data |> Enum.map(& &1.visitors) |> Enum.max(fn -> 1 end)
scale_max = nice_max(max_val)
scale_mid = div(scale_max, 2)
bar_count = max(length(data), 1)
# No gap for dense charts (12m), small gap for sparse (today/7d)
bar_gap = if bar_count > 60, do: 0, else: 1
bars =
data
|> Enum.with_index()
|> Enum.map(fn {entry, i} ->
visitors = entry.visitors
height_pct = if scale_max > 0, do: visitors / scale_max * 100, else: 0
label =
case assigns.mode do
:hourly -> "#{entry.hour}:00"
:daily -> format_date_label(entry.date)
end
%{height_pct: height_pct, label: label, visitors: visitors, index: i}
end)
# X-axis labels
x_labels =
cond do
# 12m: show month names at month boundaries
bar_count > 60 ->
data
|> Enum.with_index()
|> Enum.chunk_by(fn {entry, _i} ->
date = parse_date(entry.date)
{date.year, date.month}
end)
|> Enum.map(fn [{entry, i} | _] ->
date = parse_date(entry.date)
%{label: Calendar.strftime(date, "%b"), index: i}
end)
# Skip first partial month if it starts mid-month
|> then(fn labels ->
case labels do
[first | rest] ->
date = parse_date(Enum.at(data, first.index).date)
if date.day > 7, do: rest, else: labels
_ ->
labels
end
end)
# Hourly: 6 evenly spaced labels
assigns.mode == :hourly ->
step = bar_count / 6
Enum.filter(bars, fn bar -> rem(bar.index, max(round(step), 1)) == 0 end)
# 30d: weekly intervals
bar_count > 14 ->
Enum.filter(bars, fn bar -> rem(bar.index, 7) == 0 end)
# 7d: every bar gets a label
true ->
bars
end
assigns =
assign(assigns,
bars: bars,
scale_max: scale_max,
scale_mid: scale_mid,
x_labels: x_labels,
bar_gap: bar_gap
)
~H"""
<div
:if={@data == []}
style="text-align: center; padding: 2rem; color: color-mix(in oklch, var(--color-base-content) 40%, transparent);"
>
No data for this period
</div>
<div
:if={@data != []}
id="analytics-chart"
phx-hook="ChartTooltip"
style="display: grid; grid-template-columns: auto 1fr; gap: 0 0.5rem; position: relative;"
>
<%!-- Tooltip --%>
<div
data-tooltip
style="display: none; position: absolute; top: -1.75rem; z-index: 10; padding: 0.25rem 0.5rem; font-size: 0.75rem; font-weight: 500; white-space: nowrap; background: var(--color-base-content, #1e1e1e); color: var(--color-base-100, #fff); border-radius: 0.25rem; pointer-events: none; font-variant-numeric: tabular-nums;"
>
</div>
<%!-- Row 1: Y-axis labels + chart --%>
<div
class="analytics-y-labels"
style="display: flex; flex-direction: column; justify-content: space-between; align-items: flex-end; height: 8rem;"
>
<span>{format_number(@scale_max)}</span>
<span>{format_number(@scale_mid)}</span>
<span>0</span>
</div>
<div style="position: relative; height: 8rem;">
<%!-- Gridlines --%>
<div style="position: absolute; top: 50%; left: 0; right: 0; border-top: 1px dashed color-mix(in oklch, var(--color-base-content) 12%, transparent);">
</div>
<div style="position: absolute; bottom: 0; left: 0; right: 0; border-top: 1px solid color-mix(in oklch, var(--color-base-content) 15%, transparent);">
</div>
<%!-- Bars container --%>
<div
data-bars
style={"display: flex; align-items: flex-end; height: 100%; gap: #{@bar_gap}px;"}
>
<div
:for={bar <- @bars}
data-label={bar.label}
data-visitors={bar.visitors}
style={"flex: 1; height: #{max(bar.height_pct, 0.5)}%; background: var(--color-primary, #4f46e5); opacity: 0.8; border-radius: 1px 1px 0 0; min-width: 0;"}
>
</div>
</div>
</div>
<%!-- Row 2: empty cell + X-axis labels --%>
<div></div>
<div
class="analytics-x-labels"
style="display: flex; justify-content: space-between; padding-top: 0.25rem;"
>
<span :for={bar <- @x_labels}>{bar.label}</span>
</div>
</div>
"""
end
defp format_date_label(date) when is_binary(date) do
case Date.from_iso8601(date) do
{:ok, d} -> Calendar.strftime(d, "%b %d")
_ -> date
end
end
defp format_date_label(date), do: to_string(date)
defp parse_date(date) when is_binary(date) do
case Date.from_iso8601(date) do
{:ok, d} -> d
_ -> Date.utc_today()
end
end
defp parse_date(%Date{} = date), do: date
defp nice_max(0), do: 10
defp nice_max(val) do
magnitude = :math.pow(10, floor(:math.log10(val)))
step = magnitude / 2
ceil(val / step) * trunc(step)
end
# ── Tab content ──
defp tab_content(%{tab: "pages"} = assigns) do
~H"""
<h3 style="font-size: 0.875rem; font-weight: 600; margin-bottom: 0.75rem;">Top pages</h3>
<.detail_table
rows={@top_pages}
empty_message="No page data yet"
columns={[
%{label: "Page", key: :pathname, filter: {:pathname, :pathname}},
%{label: "Visitors", key: :visitors, align: :right},
%{label: "Pageviews", key: :pageviews, align: :right}
]}
/>
"""
end
defp tab_content(%{tab: "sources"} = assigns) do
~H"""
<h3 style="font-size: 0.875rem; font-weight: 600; margin-bottom: 0.75rem;">Top sources</h3>
<.detail_table
rows={@top_sources}
empty_message="No referrer data yet"
columns={[
%{label: "Source", key: :source, filter: {:referrer_source, :source}},
%{label: "Visitors", key: :visitors, align: :right}
]}
/>
<h3 style="font-size: 0.875rem; font-weight: 600; margin: 1.5rem 0 0.75rem;">Top referrers</h3>
<.detail_table
rows={@top_referrers}
empty_message="No referrer data yet"
columns={[
%{label: "Referrer", key: :referrer},
%{label: "Visitors", key: :visitors, align: :right}
]}
/>
"""
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={@country_rows}
empty_message="No country data yet"
columns={[
%{label: "Country", key: :display_name, filter: {:country_code, :country_code}},
%{label: "Visitors", key: :visitors, align: :right}
]}
/>
"""
end
defp tab_content(%{tab: "devices"} = assigns) do
~H"""
<h3 style="font-size: 0.875rem; font-weight: 600; margin-bottom: 0.75rem;">Browsers</h3>
<.detail_table
rows={@browsers}
empty_message="No browser data yet"
columns={[
%{label: "Browser", key: :name, filter: {:browser, :name}},
%{label: "Visitors", key: :visitors, align: :right}
]}
/>
<h3 style="font-size: 0.875rem; font-weight: 600; margin: 1.5rem 0 0.75rem;">
Operating systems
</h3>
<.detail_table
rows={@oses}
empty_message="No OS data yet"
columns={[
%{label: "OS", key: :name, filter: {:os, :name}},
%{label: "Visitors", key: :visitors, align: :right}
]}
/>
<h3 style="font-size: 0.875rem; font-weight: 600; margin: 1.5rem 0 0.75rem;">Screen sizes</h3>
<.detail_table
rows={@screen_sizes}
empty_message="No screen data yet"
columns={[
%{label: "Size", key: :name, filter: {:screen_size, :name}},
%{label: "Visitors", key: :visitors, align: :right}
]}
/>
"""
end
defp tab_content(%{tab: "funnel"} = assigns) do
~H"""
<h3 style="font-size: 0.875rem; font-weight: 600; margin-bottom: 0.75rem;">
Conversion funnel
</h3>
<.funnel_chart funnel={@funnel} revenue={@revenue} />
"""
end
# ── Detail table ──
attr :rows, :list, required: true
attr :columns, :list, required: true
attr :empty_message, :string, default: "No data"
defp detail_table(assigns) do
~H"""
<div
:if={@rows == []}
style="text-align: center; padding: 1.5rem; color: color-mix(in oklch, var(--color-base-content) 40%, transparent);"
>
{@empty_message}
</div>
<table :if={@rows != []} class="admin-table" style="width: 100%;">
<thead>
<tr>
<th
:for={col <- @columns}
style={col[:align] == :right && "text-align: right;"}
>
{col.label}
</th>
</tr>
</thead>
<tbody>
<tr :for={row <- @rows}>
<td
:for={col <- @columns}
style={col[:align] == :right && "text-align: right; font-variant-numeric: tabular-nums;"}
>
<%= 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>
</table>
"""
end
# ── Funnel chart ──
attr :funnel, :map, required: true
attr :revenue, :integer, required: true
defp funnel_chart(assigns) do
steps = [
{"Product views", assigns.funnel.product_views},
{"Add to cart", assigns.funnel.add_to_carts},
{"Checkout", assigns.funnel.checkouts},
{"Purchase", assigns.funnel.purchases}
]
top_count = assigns.funnel.product_views
conversion_rate =
if top_count > 0,
do: Float.round(assigns.funnel.purchases / top_count * 100, 1),
else: 0.0
steps_with_rates =
steps
|> Enum.with_index()
|> Enum.map(fn {{label, count}, i} ->
overall_rate = if top_count > 0, do: Float.round(count / top_count * 100, 1), else: 0.0
width_pct = if top_count > 0, do: max(count / top_count * 100, 5), else: 5
%{label: label, count: count, overall_rate: overall_rate, width_pct: width_pct, index: i}
end)
assigns = assign(assigns, steps: steps_with_rates, conversion_rate: conversion_rate)
~H"""
<div
:if={@funnel.product_views == 0}
style="text-align: center; padding: 1.5rem; color: color-mix(in oklch, var(--color-base-content) 40%, transparent);"
>
No funnel data yet
</div>
<div :if={@funnel.product_views > 0} style="display: flex; flex-direction: column; gap: 0.5rem;">
<div :for={step <- @steps} style="display: flex; align-items: center; gap: 0.75rem;">
<div style="width: 7rem; font-size: 0.8125rem; text-align: right; flex-shrink: 0;">
{step.label}
</div>
<div style={"flex: 0 0 #{step.width_pct}%; height: 2rem; background: var(--color-primary, #4f46e5); border-radius: 0.25rem; opacity: #{1 - step.index * 0.15}; display: flex; align-items: center; padding-left: 0.5rem;"}>
<span style="font-size: 0.75rem; font-weight: 600; color: white;">
{format_number(step.count)}
</span>
</div>
<span
:if={step.index > 0}
style="font-size: 0.8125rem; font-weight: 600;"
>
{step.overall_rate}%
</span>
</div>
<div style="margin-top: 0.75rem; font-size: 0.875rem; display: flex; gap: 0.5rem; flex-wrap: wrap;">
<span style="font-weight: 600;">{@conversion_rate}% overall conversion</span>
<span
:if={@revenue > 0}
style="color: color-mix(in oklch, var(--color-base-content) 60%, transparent);"
>
· Revenue: {Cart.format_price(@revenue)}
</span>
</div>
</div>
"""
end
# ── Helpers ──
defp period_label("today"), do: "Today"
defp period_label("7d"), do: "7 days"
defp period_label("30d"), do: "30 days"
defp period_label("12m"), do: "12 months"
defp format_number(n) when n >= 1_000_000, do: "#{Float.round(n / 1_000_000, 1)}M"
defp format_number(n) when n >= 1_000, do: "#{Float.round(n / 1_000, 1)}k"
defp format_number(n), do: to_string(n)
defp format_duration(seconds) when seconds < 60, do: "#{seconds}s"
defp format_duration(seconds) do
mins = div(seconds, 60)
secs = rem(seconds, 60)
"#{mins}m #{secs}s"
end
@country_names %{
"GB" => "United Kingdom",
"US" => "United States",
"CA" => "Canada",
"AU" => "Australia",
"DE" => "Germany",
"FR" => "France",
"NL" => "Netherlands",
"IE" => "Ireland",
"AT" => "Austria",
"BE" => "Belgium",
"IT" => "Italy",
"ES" => "Spain",
"PT" => "Portugal",
"SE" => "Sweden",
"NO" => "Norway",
"DK" => "Denmark",
"FI" => "Finland",
"PL" => "Poland",
"CH" => "Switzerland",
"NZ" => "New Zealand",
"JP" => "Japan",
"IN" => "India",
"BR" => "Brazil",
"MX" => "Mexico"
}
defp country_name(code) do
Map.get(@country_names, code, code)
end
end