defmodule BerrypodWeb.Admin.Analytics do use BerrypodWeb, :live_view alias Berrypod.Analytics alias Berrypod.Cart @periods %{ "today" => 0, "7d" => 6, "30d" => 29, "12m" => 364 } @impl true def mount(_params, _session, socket) do {:ok, socket |> assign(:page_title, "Analytics") |> assign(:period, "30d") |> assign(:tab, "pages") |> 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 # ── Data loading ── defp load_analytics(socket, period) do range = date_range(period) socket |> assign(:visitors, Analytics.count_visitors(range)) |> assign(:pageviews, Analytics.count_pageviews(range)) |> assign(:bounce_rate, Analytics.bounce_rate(range)) |> assign(:avg_duration, Analytics.avg_duration(range)) |> assign(:visitors_by_date, Analytics.visitors_by_date(range)) |> 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)) 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 # ── Render ── @impl true def render(assigns) do ~H""" <.header>Analytics <%!-- Period selector --%>
<%!-- Stat cards --%>
<.stat_card label="Unique visitors" value={format_number(@visitors)} icon="hero-users" /> <.stat_card label="Total pageviews" value={format_number(@pageviews)} icon="hero-eye" /> <.stat_card label="Bounce rate" value={"#{@bounce_rate}%"} icon="hero-arrow-uturn-left" /> <.stat_card label="Visit duration" value={format_duration(@avg_duration)} icon="hero-clock" />
<%!-- Visitor trend chart --%>

Visitors over time

<.bar_chart data={@visitors_by_date} />
<%!-- 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 defp stat_card(assigns) do ~H"""
<.icon name={@icon} class="size-5" />

{@value}

{@label}

""" end # ── Bar chart (server-rendered SVG) ── attr :data, :list, required: true defp bar_chart(assigns) do data = assigns.data max_val = data |> Enum.map(& &1.visitors) |> Enum.max(fn -> 1 end) chart_height = 120 bar_count = max(length(data), 1) bars = data |> Enum.with_index() |> Enum.map(fn {%{date: date, visitors: visitors}, i} -> bar_height = if max_val > 0, do: visitors / max_val * chart_height, else: 0 bar_width = max(800 / bar_count - 2, 1) x = i * (800 / bar_count) + 1 %{ x: x, y: chart_height - bar_height, width: bar_width, height: max(bar_height, 1), date: date, visitors: visitors } end) assigns = assign(assigns, bars: bars, chart_height: chart_height) ~H"""
No data for this period
{bar.date}: {bar.visitors} visitors """ 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}, %{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}, %{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 ~H"""

Countries

<.detail_table rows={Enum.map(@top_countries, fn c -> %{c | country_code: country_name(c.country_code)} end)} empty_message="No country data yet" columns={[ %{label: "Country", key: :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}, %{label: "Visitors", key: :visitors, align: :right} ]} />

Operating systems

<.detail_table rows={@oses} empty_message="No OS data yet" columns={[ %{label: "OS", key: :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}, %{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}
{Map.get(row, col.key)}
""" 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