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 <%!-- Period selector --%>
<%!-- Active filters --%>
<.filter_chip :for={{dim, val} <- @filters} dimension={dim} value={val} />
<%!-- Stat cards --%>
<.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} />
<%!-- Visitor trend chart --%>

Visitors over time

<.bar_chart data={@trend_data} mode={@trend_mode} />
<%!-- Detail tabs --%>
<%!-- Tab content --%>
<.tab_content tab={@tab} {assigns} />
""" 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"""
<.icon name={@icon} class="size-5" />

{@value}

<.delta_badge :if={@delta != nil} delta={@delta} invert={@invert} />

{@label}

""" end attr :delta, :any, required: true attr :invert, :boolean, required: true defp delta_badge(%{delta: :new} = assigns) do ~H""" new """ 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""" {@arrow} {if abs(@delta) > 999, do: ">999%", else: "#{abs(@delta)}%"} """ 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""" {@label} """ 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"""
No data for this period
<%!-- Tooltip --%>
<%!-- Row 1: Y-axis labels + chart --%>
{format_number(@scale_max)} {format_number(@scale_mid)} 0
<%!-- Gridlines --%>
<%!-- Bars container --%>
<%!-- Row 2: empty cell + X-axis labels --%>
{bar.label}
""" 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"""

Top pages

<.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"""

Top sources

<.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} ]} />

Top referrers

<.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"""

Countries

<.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"""

Browsers

<.detail_table rows={@browsers} empty_message="No browser data yet" columns={[ %{label: "Browser", key: :name, filter: {:browser, :name}}, %{label: "Visitors", key: :visitors, align: :right} ]} />

Operating systems

<.detail_table rows={@oses} empty_message="No OS data yet" columns={[ %{label: "OS", key: :name, filter: {:os, :name}}, %{label: "Visitors", key: :visitors, align: :right} ]} />

Screen sizes

<.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"""

Conversion funnel

<.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"""
{@empty_message}
{col.label}
<%= if col[:filter] do %> {Map.get(row, col.key)} <% else %> {Map.get(row, col.key)} <% end %>
""" 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"""
No funnel data yet
0} style="display: flex; flex-direction: column; gap: 0.5rem;">
{step.label}
{format_number(step.count)}
0} style="font-size: 0.8125rem; font-weight: 600;" > {step.overall_rate}%
{@conversion_rate}% overall conversion 0} style="color: color-mix(in oklch, var(--color-base-content) 60%, transparent);" > · Revenue: {Cart.format_price(@revenue)}
""" 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