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:
parent
01ff8decd5
commit
758e66db5c
@ -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)
|
||||
const AnalyticsInit = {
|
||||
mounted() {
|
||||
@ -592,7 +617,7 @@ const ChartTooltip = {
|
||||
const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
|
||||
const liveSocket = new LiveSocket("/live", Socket, {
|
||||
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
|
||||
|
||||
209
lib/berrypod_web/controllers/analytics_export_controller.ex
Normal file
209
lib/berrypod_web/controllers/analytics_export_controller.ex
Normal 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
|
||||
@ -144,15 +144,30 @@ defmodule BerrypodWeb.Admin.Analytics do
|
||||
<.header>Analytics</.header>
|
||||
|
||||
<%!-- 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
|
||||
:for={period <- ["today", "7d", "30d", "12m"]}
|
||||
phx-click="change_period"
|
||||
phx-value-period={period}
|
||||
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 --%>
|
||||
@ -308,7 +323,11 @@ defmodule BerrypodWeb.Admin.Analytics do
|
||||
assigns = assign(assigns, label: filter_label(assigns.dimension, assigns.value))
|
||||
|
||||
~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}
|
||||
<button
|
||||
phx-click="remove_filter"
|
||||
@ -745,6 +764,15 @@ defmodule BerrypodWeb.Admin.Analytics do
|
||||
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)
|
||||
|
||||
@ -187,6 +187,8 @@ defmodule BerrypodWeb.Router do
|
||||
scope "/admin", BerrypodWeb do
|
||||
pipe_through [:browser, :require_authenticated_user, :admin]
|
||||
|
||||
get "/analytics/export", AnalyticsExportController, :export
|
||||
|
||||
live_session :admin,
|
||||
layout: {BerrypodWeb.Layouts, :admin},
|
||||
on_mount: [
|
||||
|
||||
Loading…
Reference in New Issue
Block a user