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>
210 lines
5.9 KiB
Elixir
210 lines
5.9 KiB
Elixir
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
|