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 <noreply@anthropic.com>
This commit is contained in:
jamey 2026-02-22 23:28:35 +00:00
parent 65e646a7eb
commit 08fcd60eb6
3 changed files with 130 additions and 35 deletions

View File

@ -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 {

View File

@ -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.
"""

View File

@ -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
<h3 style="font-size: 0.875rem; font-weight: 600; margin-bottom: 0.75rem;">
Visitors over time
</h3>
<.bar_chart data={@visitors_by_date} />
<.bar_chart data={@trend_data} mode={@trend_mode} />
</div>
<%!-- 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"""
<div
@ -190,28 +220,61 @@ defmodule BerrypodWeb.Admin.Analytics do
>
No data for this period
</div>
<svg
:if={@data != []}
viewBox={"0 0 800 #{@chart_height}"}
style="width: 100%; height: auto; max-height: 160px;"
aria-label="Visitor trend chart"
<div :if={@data != []} style="display: grid; grid-template-columns: auto 1fr; gap: 0 0.5rem;">
<%!-- Row 1: Y-axis labels + chart --%>
<div
class="analytics-y-labels"
style="display: flex; flex-direction: column; justify-content: space-between; align-items: flex-end; height: 8rem;"
>
<rect
<span>{format_number(@scale_max)}</span>
<span>{format_number(@scale_mid)}</span>
<span>0</span>
</div>
<div style="position: relative; height: 8rem;">
<%!-- Gridlines --%>
<div style="position: absolute; top: 50%; left: 0; right: 0; border-top: 1px dashed color-mix(in oklch, var(--color-base-content) 12%, transparent);">
</div>
<div style="position: absolute; bottom: 0; left: 0; right: 0; border-top: 1px solid color-mix(in oklch, var(--color-base-content) 15%, transparent);">
</div>
<%!-- Bars container --%>
<div style={"display: flex; align-items: flex-end; height: 100%; gap: #{@bar_gap}px;"}>
<div
:for={bar <- @bars}
x={bar.x}
y={bar.y}
width={bar.width}
height={bar.height}
rx="2"
fill="var(--color-primary, #4f46e5)"
opacity="0.8"
style={"flex: 1; height: #{max(bar.height_pct, 0.5)}%; background: var(--color-primary, #4f46e5); opacity: 0.8; border-radius: 1px 1px 0 0; min-width: 0;"}
title={"#{bar.label}: #{bar.visitors} visitors"}
>
<title>{bar.date}: {bar.visitors} visitors</title>
</rect>
</svg>
</div>
</div>
</div>
<%!-- Row 2: empty cell + X-axis labels --%>
<div></div>
<div
class="analytics-x-labels"
style="display: flex; justify-content: space-between; padding-top: 0.25rem;"
>
<span :for={bar <- @x_labels}>{bar.label}</span>
</div>
</div>
"""
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