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)
|
// 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
|
||||||
|
|||||||
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>
|
<.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)
|
||||||
|
|||||||
@ -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: [
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user