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
-