berrypod/lib/berrypod_web/live/admin/analytics.ex
jamey 758e66db5c
All checks were successful
deploy / deploy (push) Successful in 1m25s
add analytics CSV export
Downloads a ZIP with one CSV per report (overview, trend, pages, entry/exit
pages, sources, referrers, countries, devices, funnel). Export button lives
next to the period selector and picks up the current period and any active
filters using a JS hook + JS.set_attribute, so the downloaded data always
matches what's on screen.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 09:37:45 +00:00

819 lines
26 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"
style="display: flex; gap: 0.25rem; margin-top: 1rem; align-items: center;"
>
<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"
style="margin-left: auto;"
phx-hook="AnalyticsExport"
>
Export CSV
</a>
</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
data-filter-dimension={@dimension}
data-filter-value={@value}
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}
]}
/>
<h3 style="font-size: 0.875rem; font-weight: 600; margin: 1.5rem 0 0.75rem;">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 style="font-size: 0.875rem; font-weight: 600; margin: 1.5rem 0 0.75rem;">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 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 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