From 08fcd60eb64eea6902fb9c7cf3af2de697fa22c0 Mon Sep 17 00:00:00 2001 From: jamey Date: Sun, 22 Feb 2026 23:28:35 +0000 Subject: [PATCH] improve analytics chart with hourly today view and readable labels - add visitors_by_hour query for hourly breakdown on "today" period - replace SVG-only chart with HTML/CSS grid layout (bars + labels) - Y-axis scale with nice rounded max, midpoint, and zero - X-axis date labels (formatted as "Feb 18") spaced evenly - adaptive bar gaps (1px for sparse data, 0 for 365-day dense view) - labels use real HTML text so they're readable on mobile Co-Authored-By: Claude Opus 4.6 --- assets/css/admin/components.css | 9 ++ lib/berrypod/analytics.ex | 23 ++++ lib/berrypod_web/live/admin/analytics.ex | 133 +++++++++++++++++------ 3 files changed, 130 insertions(+), 35 deletions(-) diff --git a/assets/css/admin/components.css b/assets/css/admin/components.css index ef7fde2..8d4fabe 100644 --- a/assets/css/admin/components.css +++ b/assets/css/admin/components.css @@ -662,6 +662,15 @@ } } +/* ── Analytics chart labels ── */ + +.analytics-y-labels, +.analytics-x-labels { + font-size: 0.6875rem; + color: color-mix(in oklch, var(--color-base-content) 55%, transparent); + font-variant-numeric: tabular-nums; +} + /* ── Setup page ── */ .setup-page { diff --git a/lib/berrypod/analytics.ex b/lib/berrypod/analytics.ex index 2fda196..84dffde 100644 --- a/lib/berrypod/analytics.ex +++ b/lib/berrypod/analytics.ex @@ -148,6 +148,29 @@ defmodule Berrypod.Analytics do |> Repo.all() end + @doc """ + Hourly visitor counts for the trend chart (used for "today" period). + + Returns a list of `%{hour: integer, visitors: integer}` maps for all 24 hours. + """ + def visitors_by_hour(date_range) do + counts = + base_query(date_range) + |> where([e], e.name == "pageview") + |> group_by([e], fragment("CAST(strftime('%H', ?) AS INTEGER)", e.inserted_at)) + |> select([e], %{ + hour: fragment("CAST(strftime('%H', ?) AS INTEGER)", e.inserted_at), + visitors: count(e.visitor_hash, :distinct) + }) + |> Repo.all() + |> Map.new(&{&1.hour, &1.visitors}) + + # Fill in all 24 hours so the chart has no gaps + Enum.map(0..23, fn h -> + %{hour: h, visitors: Map.get(counts, h, 0)} + end) + end + @doc """ Top pages by unique visitors. """ diff --git a/lib/berrypod_web/live/admin/analytics.ex b/lib/berrypod_web/live/admin/analytics.ex index 09fc5cf..4677c8b 100644 --- a/lib/berrypod_web/live/admin/analytics.ex +++ b/lib/berrypod_web/live/admin/analytics.ex @@ -40,12 +40,18 @@ defmodule BerrypodWeb.Admin.Analytics do defp load_analytics(socket, period) do range = date_range(period) + trend_data = + if period == "today", + do: Analytics.visitors_by_hour(range), + else: Analytics.visitors_by_date(range) + socket |> assign(:visitors, Analytics.count_visitors(range)) |> assign(:pageviews, Analytics.count_pageviews(range)) |> assign(:bounce_rate, Analytics.bounce_rate(range)) |> assign(:avg_duration, Analytics.avg_duration(range)) - |> assign(:visitors_by_date, Analytics.visitors_by_date(range)) + |> assign(:trend_data, trend_data) + |> assign(:trend_mode, if(period == "today", do: :hourly, else: :daily)) |> assign(:top_pages, Analytics.top_pages(range)) |> assign(:top_sources, Analytics.top_sources(range)) |> assign(:top_referrers, Analytics.top_referrers(range)) @@ -99,7 +105,7 @@ defmodule BerrypodWeb.Admin.Analytics do

Visitors over time

- <.bar_chart data={@visitors_by_date} /> + <.bar_chart data={@trend_data} mode={@trend_mode} /> <%!-- Detail tabs --%> @@ -153,35 +159,59 @@ defmodule BerrypodWeb.Admin.Analytics do """ end - # ── Bar chart (server-rendered SVG) ── + # ── Bar chart (HTML/CSS bars with readable labels) ── attr :data, :list, required: true + attr :mode, :atom, required: true defp bar_chart(assigns) do data = assigns.data max_val = data |> Enum.map(& &1.visitors) |> Enum.max(fn -> 1 end) - chart_height = 120 + + scale_max = nice_max(max_val) + scale_mid = div(scale_max, 2) bar_count = max(length(data), 1) + # No gap for dense charts (12m), small gap for sparse (today/7d) + bar_gap = if bar_count > 60, do: 0, else: 1 bars = data |> Enum.with_index() - |> Enum.map(fn {%{date: date, visitors: visitors}, i} -> - bar_height = if max_val > 0, do: visitors / max_val * chart_height, else: 0 - bar_width = max(800 / bar_count - 2, 1) - x = i * (800 / bar_count) + 1 + |> Enum.map(fn {entry, i} -> + visitors = entry.visitors + height_pct = if scale_max > 0, do: visitors / scale_max * 100, else: 0 - %{ - x: x, - y: chart_height - bar_height, - width: bar_width, - height: max(bar_height, 1), - date: date, - visitors: visitors - } + label = + case assigns.mode do + :hourly -> "#{entry.hour}:00" + :daily -> format_date_label(entry.date) + end + + %{height_pct: height_pct, label: label, visitors: visitors, index: i} end) - assigns = assign(assigns, bars: bars, chart_height: chart_height) + # X-axis labels: pick evenly spaced bars + x_label_count = if assigns.mode == :hourly, do: 6, else: min(bar_count, 5) + + x_labels = + if bar_count <= x_label_count do + bars + else + step = bar_count / x_label_count + + Enum.filter(bars, fn bar -> + rem(bar.index, max(round(step), 1)) == 0 + end) + end + + assigns = + assign(assigns, + bars: bars, + scale_max: scale_max, + scale_mid: scale_mid, + x_labels: x_labels, + bar_gap: bar_gap + ) ~H"""
No data for this period
- - + <%!-- Row 1: Y-axis labels + chart --%> +
- {bar.date}: {bar.visitors} visitors - - + {format_number(@scale_max)} + {format_number(@scale_mid)} + 0 +
+
+ <%!-- Gridlines --%> +
+
+
+
+ <%!-- Bars container --%> +
+
+
+
+
+ <%!-- Row 2: empty cell + X-axis labels --%> +
+
+ {bar.label} +
+ """ end + defp format_date_label(date) when is_binary(date) do + case Date.from_iso8601(date) do + {:ok, d} -> Calendar.strftime(d, "%b %d") + _ -> date + end + end + + defp format_date_label(date), do: to_string(date) + + defp nice_max(0), do: 10 + + defp nice_max(val) do + magnitude = :math.pow(10, floor(:math.log10(val))) + step = magnitude / 2 + ceil(val / step) * trunc(step) + end + # ── Tab content ── defp tab_content(%{tab: "pages"} = assigns) do