diff --git a/assets/js/app.js b/assets/js/app.js index 5da03cc..0e2800f 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -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 diff --git a/lib/berrypod_web/controllers/analytics_export_controller.ex b/lib/berrypod_web/controllers/analytics_export_controller.ex new file mode 100644 index 0000000..5be4c2c --- /dev/null +++ b/lib/berrypod_web/controllers/analytics_export_controller.ex @@ -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 diff --git a/lib/berrypod_web/live/admin/analytics.ex b/lib/berrypod_web/live/admin/analytics.ex index 65f4a18..a254976 100644 --- a/lib/berrypod_web/live/admin/analytics.ex +++ b/lib/berrypod_web/live/admin/analytics.ex @@ -144,15 +144,30 @@ defmodule BerrypodWeb.Admin.Analytics do <.header>Analytics <%!-- Period selector --%> -
+
+ + Export CSV +
<%!-- Active filters --%> @@ -308,7 +323,11 @@ defmodule BerrypodWeb.Admin.Analytics do assigns = assign(assigns, label: filter_label(assigns.dimension, assigns.value)) ~H""" - + {@label}