berrypod/lib/berrypod_web/live/admin/analytics.ex
jamey ae6cf209aa complete admin CSS refactor: delete utilities.css, add layout primitives
- Delete utilities.css (701 lines / 24 KB of Tailwind utility clones)
- Add layout.css with admin-stack, admin-row, admin-cluster, admin-grid
  primitives and gap variants (sm, md, lg, xl)
- Add transitions.css import and layout.css import to admin.css entry point
- Replace all Tailwind utility classes across 26 admin templates with
  semantic admin-*/theme-*/page-specific CSS classes
- Replace all non-dynamic inline styles with semantic classes
- Add ~100 new semantic classes to components.css (analytics, dashboard,
  order detail, settings, theme editor, generic utilities)
- Fix stray text-error → admin-text-error in media.ex
- Add missing .truncate definition to admin CSS
- Only remaining inline styles are dynamic data values (progress bars,
  chart dimensions) and one JS.toggle target

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 21:40:21 +00:00

775 lines
22 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))
|> assign(:entry_pages, Analytics.entry_pages(range, filters: filters))
|> assign(:exit_pages, Analytics.exit_pages(range, filters: 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">
<button
:for={period <- ["today", "7d", "30d", "12m"]}
phx-click={
JS.push("change_period", value: %{period: period})
|> JS.set_attribute({"data-period", period}, to: "#analytics-export-link")
}
class={["admin-btn admin-btn-sm", @period == period && "admin-btn-primary"]}
>
{period_label(period)}
</button>
<a
id="analytics-export-link"
data-period={@period}
href={export_url(@period, @filters)}
class="admin-btn admin-btn-sm analytics-export"
phx-hook="AnalyticsExport"
>
Export CSV
</a>
</div>
<%!-- Active filters --%>
<div
:if={@filters != %{}}
id="analytics-filters"
class="analytics-filters"
>
<.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 admin-text-secondary"
>
Clear all
</button>
</div>
<%!-- Stat cards --%>
<div class="admin-stats-grid admin-card-spaced">
<.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 admin-card-spaced analytics-chart-card">
<h3 class="analytics-tab-heading">
Visitors over time
</h3>
<.bar_chart data={@trend_data} mode={@trend_mode} />
</div>
<%!-- Detail tabs --%>
<div class="analytics-tab-bar">
<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 analytics-tab-panel">
<.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 class="admin-stat-card-body">
<div class="admin-stat-icon">
<.icon name={@icon} class="size-5" />
</div>
<div>
<div class="admin-stat-value-row">
<p class="admin-stat-value">{@value}</p>
<.delta_badge :if={@delta != nil} delta={@delta} invert={@invert} />
</div>
<p class="admin-stat-label">{@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 class="analytics-delta admin-text-secondary">new</span>
"""
end
defp delta_badge(assigns) do
{color_class, arrow} =
cond do
assigns.delta > 0 && !assigns.invert -> {"admin-icon-positive", ""}
assigns.delta > 0 && assigns.invert -> {"admin-text-error", ""}
assigns.delta < 0 && !assigns.invert -> {"admin-text-error", ""}
assigns.delta < 0 && assigns.invert -> {"admin-icon-positive", ""}
true -> {"admin-text-secondary", ""}
end
assigns = assign(assigns, color_class: color_class, arrow: arrow)
~H"""
<span class={["analytics-delta", @color_class]}>
{@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
data-filter-dimension={@dimension}
data-filter-value={@value}
class="analytics-filter-chip"
>
{@label}
<button
phx-click="remove_filter"
phx-value-dimension={@dimension}
class="analytics-filter-remove"
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 == []} class="analytics-empty">
No data for this period
</div>
<div
:if={@data != []}
id="analytics-chart"
phx-hook="ChartTooltip"
class="analytics-chart-grid"
>
<%!-- Tooltip --%>
<div data-tooltip class="analytics-tooltip"></div>
<%!-- Row 1: Y-axis labels + chart --%>
<div class="analytics-y-labels">
<span>{format_number(@scale_max)}</span>
<span>{format_number(@scale_mid)}</span>
<span>0</span>
</div>
<div class="analytics-chart-area">
<%!-- Gridlines --%>
<div class="analytics-gridline-mid"></div>
<div class="analytics-gridline-bottom"></div>
<%!-- Bars container --%>
<div data-bars class="analytics-bars" style={"gap: #{@bar_gap}px;"}>
<div
:for={bar <- @bars}
data-label={bar.label}
data-visitors={bar.visitors}
class="analytics-bar"
style={"height: #{max(bar.height_pct, 0.5)}%;"}
>
</div>
</div>
</div>
<%!-- Row 2: empty cell + X-axis labels --%>
<div></div>
<div class="analytics-x-labels">
<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 class="analytics-tab-heading">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}
]}
/>
<h3 class="analytics-tab-heading-spaced">Entry pages</h3>
<.detail_table
rows={@entry_pages}
empty_message="No entry page data yet"
columns={[
%{label: "Page", key: :pathname, filter: {:pathname, :pathname}},
%{label: "Sessions", key: :sessions, align: :right}
]}
/>
<h3 class="analytics-tab-heading-spaced">Exit pages</h3>
<.detail_table
rows={@exit_pages}
empty_message="No exit page data yet"
columns={[
%{label: "Page", key: :pathname, filter: {:pathname, :pathname}},
%{label: "Sessions", key: :sessions, align: :right}
]}
/>
"""
end
defp tab_content(%{tab: "sources"} = assigns) do
~H"""
<h3 class="analytics-tab-heading">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 class="analytics-tab-heading-spaced">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 class="analytics-tab-heading">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 class="analytics-tab-heading">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 class="analytics-tab-heading-spaced">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 class="analytics-tab-heading-spaced">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 class="analytics-tab-heading">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 == []} class="analytics-empty">
{@empty_message}
</div>
<table :if={@rows != []} class="admin-table">
<thead>
<tr>
<th
:for={col <- @columns}
class={col[:align] == :right && "admin-cell-end"}
>
{col.label}
</th>
</tr>
</thead>
<tbody>
<tr :for={row <- @rows}>
<td
:for={col <- @columns}
class={col[:align] == :right && "admin-cell-numeric"}
>
<%= if col[:filter] do %>
<span
class="admin-link"
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} class="analytics-empty">
No funnel data yet
</div>
<div :if={@funnel.product_views > 0} class="analytics-funnel">
<div :for={step <- @steps} class="analytics-funnel-step">
<div class="analytics-funnel-label">{step.label}</div>
<div
class="analytics-funnel-bar"
style={"flex: 0 0 #{step.width_pct}%; opacity: #{1 - step.index * 0.15};"}
>
<span class="analytics-funnel-value">{format_number(step.count)}</span>
</div>
<span :if={step.index > 0} class="analytics-funnel-rate">
{step.overall_rate}%
</span>
</div>
<div class="analytics-funnel-summary">
<span class="admin-text-bold">{@conversion_rate}% overall conversion</span>
<span :if={@revenue > 0} class="admin-text-secondary">
· 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 export_url(period, filters) do
params =
Enum.reduce(filters, %{"period" => period}, fn {k, v}, acc ->
Map.put(acc, "filter[#{k}]", v)
end)
"/admin/analytics/export?" <> URI.encode_query(params)
end
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