add analytics CSV export
All checks were successful
deploy / deploy (push) Successful in 1m25s

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>
This commit is contained in:
jamey 2026-02-24 09:37:45 +00:00
parent 01ff8decd5
commit 758e66db5c
4 changed files with 269 additions and 5 deletions

View File

@ -509,6 +509,31 @@ const CardRadioScroll = {
} }
} }
// Analytics export: reads the current period and filters from the DOM at click time
// so the download URL is always correct, even if clicked before the LiveView re-render.
const AnalyticsExport = {
mounted() {
this.el.addEventListener("click", (e) => {
e.preventDefault()
const params = new URLSearchParams()
params.set("period", this.el.getAttribute("data-period") || "30d")
document.querySelectorAll("[data-filter-dimension]").forEach((span) => {
const dim = span.getAttribute("data-filter-dimension")
const val = span.getAttribute("data-filter-value")
if (dim && val) params.set(`filter[${dim}]`, val)
})
// Use a temporary anchor click so the download doesn't navigate away and
// kill the LiveView WebSocket.
const a = document.createElement("a")
a.href = `/admin/analytics/export?${params}`
a.download = ""
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
})
}
}
// Analytics: send screen width for device classification (Layer 3) // Analytics: send screen width for device classification (Layer 3)
const AnalyticsInit = { const AnalyticsInit = {
mounted() { mounted() {
@ -592,7 +617,7 @@ const ChartTooltip = {
const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
const liveSocket = new LiveSocket("/live", Socket, { const liveSocket = new LiveSocket("/live", Socket, {
params: {_csrf_token: csrfToken, screen_width: window.innerWidth}, params: {_csrf_token: csrfToken, screen_width: window.innerWidth},
hooks: {...colocatedHooks, ColorSync, Lightbox, CartPersist, CartDrawer, ProductImageScroll, SearchModal, CollectionFilters, CardRadioScroll, AnalyticsInit, ChartTooltip}, hooks: {...colocatedHooks, ColorSync, Lightbox, CartPersist, CartDrawer, ProductImageScroll, SearchModal, CollectionFilters, CardRadioScroll, AnalyticsInit, AnalyticsExport, ChartTooltip},
}) })
// Show progress bar on live navigation and form submits // Show progress bar on live navigation and form submits

View File

@ -0,0 +1,209 @@
defmodule BerrypodWeb.AnalyticsExportController do
use BerrypodWeb, :controller
alias Berrypod.Analytics
@periods %{
"today" => 0,
"7d" => 6,
"30d" => 29,
"12m" => 364
}
@filterable ~w(pathname referrer_source country_code browser os screen_size)
def export(conn, params) do
period = if Map.has_key?(@periods, params["period"]), do: params["period"], else: "30d"
filters = parse_filters(params)
range = date_range(period)
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)
top_pages = Analytics.top_pages(range, filters: filters, limit: 10_000)
entry_pages = Analytics.entry_pages(range, filters: filters, limit: 10_000)
exit_pages = Analytics.exit_pages(range, filters: filters, limit: 10_000)
top_sources = Analytics.top_sources(range, filters: filters, limit: 10_000)
top_referrers = Analytics.top_referrers(range, filters: filters, limit: 10_000)
countries = Analytics.top_countries(range, filters: filters, limit: 10_000)
browsers = Analytics.device_breakdown(range, :browser, filters)
oses = Analytics.device_breakdown(range, :os, filters)
screen_sizes = Analytics.device_breakdown(range, :screen_size, filters)
funnel = Analytics.funnel(range, filters)
revenue = Analytics.total_revenue(range, filters)
csv_files = [
{"overview.csv", overview_csv(visitors, pageviews, bounce_rate, avg_duration)},
{trend_filename(period), trend_csv(trend_data, period)},
{"pages.csv", pages_csv(top_pages)},
{"entry_pages.csv", entry_exit_csv(entry_pages)},
{"exit_pages.csv", entry_exit_csv(exit_pages)},
{"sources.csv", sources_csv(top_sources)},
{"referrers.csv", referrers_csv(top_referrers)},
{"countries.csv", countries_csv(countries)},
{"browsers.csv", devices_csv(browsers)},
{"operating_systems.csv", devices_csv(oses)},
{"screen_sizes.csv", devices_csv(screen_sizes)},
{"funnel.csv", funnel_csv(funnel, revenue)}
]
zip_entries =
Enum.map(csv_files, fn {name, content} ->
{String.to_charlist(name), content}
end)
{:ok, {_name, zip_data}} = :zip.create(~c"export.zip", zip_entries, [:memory])
today = Date.to_iso8601(Date.utc_today())
filename = "analytics-#{period}-#{today}.zip"
conn
|> put_resp_content_type("application/zip")
|> put_resp_header("content-disposition", ~s(attachment; filename="#{filename}"))
|> send_resp(200, zip_data)
end
# -- CSV builders --
defp overview_csv(visitors, pageviews, bounce_rate, avg_duration) do
encode_csv([
["metric", "value"],
["visitors", visitors],
["pageviews", pageviews],
["bounce_rate_pct", bounce_rate],
["avg_duration_seconds", avg_duration]
])
end
defp trend_filename("today"), do: "visitors_by_hour.csv"
defp trend_filename(_), do: "visitors_by_date.csv"
defp trend_csv(data, "today") do
rows = [["hour", "visitors"] | Enum.map(data, fn r -> [r.hour, r.visitors] end)]
encode_csv(rows)
end
defp trend_csv(data, _period) do
rows = [["date", "visitors"] | Enum.map(data, fn r -> [r.date, r.visitors] end)]
encode_csv(rows)
end
defp pages_csv(rows) do
encode_csv([
["pathname", "visitors", "pageviews"]
| Enum.map(rows, fn r -> [r.pathname, r.visitors, r.pageviews] end)
])
end
defp entry_exit_csv(rows) do
encode_csv([
["pathname", "sessions"]
| Enum.map(rows, fn r -> [r.pathname, r.sessions] end)
])
end
defp sources_csv(rows) do
encode_csv([
["source", "visitors"]
| Enum.map(rows, fn r -> [r.source, r.visitors] end)
])
end
defp referrers_csv(rows) do
encode_csv([
["referrer", "visitors"]
| Enum.map(rows, fn r -> [r.referrer, r.visitors] end)
])
end
defp countries_csv(rows) do
encode_csv([
["country_code", "visitors"]
| Enum.map(rows, fn r -> [r.country_code, r.visitors] end)
])
end
defp devices_csv(rows) do
encode_csv([
["name", "visitors"]
| Enum.map(rows, fn r -> [r.name, r.visitors] end)
])
end
defp funnel_csv(funnel, revenue) do
top = funnel.product_views
steps = [
{"product_views", funnel.product_views},
{"add_to_carts", funnel.add_to_carts},
{"checkouts", funnel.checkouts},
{"purchases", funnel.purchases}
]
step_rows =
Enum.map(steps, fn {name, count} ->
pct = if top > 0, do: Float.round(count / top * 100, 1), else: 0.0
[name, count, pct]
end)
encode_csv(
[["step", "count", "conversion_pct"]] ++ step_rows ++ [["revenue_pence", revenue, ""]]
)
end
# -- CSV encoding --
defp encode_csv(rows) do
rows
|> Enum.map(fn row ->
row
|> Enum.map(&encode_field/1)
|> Enum.join(",")
end)
|> Enum.join("\r\n")
|> then(&(&1 <> "\r\n"))
end
defp encode_field(nil), do: ""
defp encode_field(val) when is_binary(val) do
if String.contains?(val, [",", "\"", "\n", "\r"]) do
~s("#{String.replace(val, "\"", "\"\"")}")
else
val
end
end
defp encode_field(val), do: to_string(val)
# -- Helpers --
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 parse_filters(params) do
filter_params = Map.get(params, "filter", %{})
Enum.reduce(filter_params, %{}, fn {key, val}, acc ->
if key in @filterable do
Map.put(acc, String.to_existing_atom(key), val)
else
acc
end
end)
end
end

View File

@ -144,15 +144,30 @@ defmodule BerrypodWeb.Admin.Analytics do
<.header>Analytics</.header> <.header>Analytics</.header>
<%!-- Period selector --%> <%!-- Period selector --%>
<div class="analytics-periods" style="display: flex; gap: 0.25rem; margin-top: 1rem;"> <div
class="analytics-periods"
style="display: flex; gap: 0.25rem; margin-top: 1rem; align-items: center;"
>
<button <button
:for={period <- ["today", "7d", "30d", "12m"]} :for={period <- ["today", "7d", "30d", "12m"]}
phx-click="change_period" phx-click={
phx-value-period={period} 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"]} class={["admin-btn admin-btn-sm", @period == period && "admin-btn-primary"]}
> >
{period_label(period)} {period_label(period)}
</button> </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> </div>
<%!-- Active filters --%> <%!-- Active filters --%>
@ -308,7 +323,11 @@ defmodule BerrypodWeb.Admin.Analytics do
assigns = assign(assigns, label: filter_label(assigns.dimension, assigns.value)) assigns = assign(assigns, label: filter_label(assigns.dimension, assigns.value))
~H""" ~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;"> <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} {@label}
<button <button
phx-click="remove_filter" phx-click="remove_filter"
@ -745,6 +764,15 @@ defmodule BerrypodWeb.Admin.Analytics do
defp period_label("30d"), do: "30 days" defp period_label("30d"), do: "30 days"
defp period_label("12m"), do: "12 months" 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_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) when n >= 1_000, do: "#{Float.round(n / 1_000, 1)}k"
defp format_number(n), do: to_string(n) defp format_number(n), do: to_string(n)

View File

@ -187,6 +187,8 @@ defmodule BerrypodWeb.Router do
scope "/admin", BerrypodWeb do scope "/admin", BerrypodWeb do
pipe_through [:browser, :require_authenticated_user, :admin] pipe_through [:browser, :require_authenticated_user, :admin]
get "/analytics/export", AnalyticsExportController, :export
live_session :admin, live_session :admin,
layout: {BerrypodWeb.Layouts, :admin}, layout: {BerrypodWeb.Layouts, :admin},
on_mount: [ on_mount: [