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:
parent
65e646a7eb
commit
08fcd60eb6
@ -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 ── */
|
||||||
|
|
||||||
.setup-page {
|
.setup-page {
|
||||||
|
|||||||
@ -148,6 +148,29 @@ defmodule Berrypod.Analytics do
|
|||||||
|> Repo.all()
|
|> Repo.all()
|
||||||
end
|
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 """
|
@doc """
|
||||||
Top pages by unique visitors.
|
Top pages by unique visitors.
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -40,12 +40,18 @@ defmodule BerrypodWeb.Admin.Analytics do
|
|||||||
defp load_analytics(socket, period) do
|
defp load_analytics(socket, period) do
|
||||||
range = date_range(period)
|
range = date_range(period)
|
||||||
|
|
||||||
|
trend_data =
|
||||||
|
if period == "today",
|
||||||
|
do: Analytics.visitors_by_hour(range),
|
||||||
|
else: Analytics.visitors_by_date(range)
|
||||||
|
|
||||||
socket
|
socket
|
||||||
|> assign(:visitors, Analytics.count_visitors(range))
|
|> assign(:visitors, Analytics.count_visitors(range))
|
||||||
|> assign(:pageviews, Analytics.count_pageviews(range))
|
|> assign(:pageviews, Analytics.count_pageviews(range))
|
||||||
|> assign(:bounce_rate, Analytics.bounce_rate(range))
|
|> assign(:bounce_rate, Analytics.bounce_rate(range))
|
||||||
|> assign(:avg_duration, Analytics.avg_duration(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_pages, Analytics.top_pages(range))
|
||||||
|> assign(:top_sources, Analytics.top_sources(range))
|
|> assign(:top_sources, Analytics.top_sources(range))
|
||||||
|> assign(:top_referrers, Analytics.top_referrers(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;">
|
<h3 style="font-size: 0.875rem; font-weight: 600; margin-bottom: 0.75rem;">
|
||||||
Visitors over time
|
Visitors over time
|
||||||
</h3>
|
</h3>
|
||||||
<.bar_chart data={@visitors_by_date} />
|
<.bar_chart data={@trend_data} mode={@trend_mode} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%!-- Detail tabs --%>
|
<%!-- Detail tabs --%>
|
||||||
@ -153,35 +159,59 @@ defmodule BerrypodWeb.Admin.Analytics do
|
|||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
# ── Bar chart (server-rendered SVG) ──
|
# ── Bar chart (HTML/CSS bars with readable labels) ──
|
||||||
|
|
||||||
attr :data, :list, required: true
|
attr :data, :list, required: true
|
||||||
|
attr :mode, :atom, required: true
|
||||||
|
|
||||||
defp bar_chart(assigns) do
|
defp bar_chart(assigns) do
|
||||||
data = assigns.data
|
data = assigns.data
|
||||||
max_val = data |> Enum.map(& &1.visitors) |> Enum.max(fn -> 1 end)
|
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)
|
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 =
|
bars =
|
||||||
data
|
data
|
||||||
|> Enum.with_index()
|
|> Enum.with_index()
|
||||||
|> Enum.map(fn {%{date: date, visitors: visitors}, i} ->
|
|> Enum.map(fn {entry, i} ->
|
||||||
bar_height = if max_val > 0, do: visitors / max_val * chart_height, else: 0
|
visitors = entry.visitors
|
||||||
bar_width = max(800 / bar_count - 2, 1)
|
height_pct = if scale_max > 0, do: visitors / scale_max * 100, else: 0
|
||||||
x = i * (800 / bar_count) + 1
|
|
||||||
|
|
||||||
%{
|
label =
|
||||||
x: x,
|
case assigns.mode do
|
||||||
y: chart_height - bar_height,
|
:hourly -> "#{entry.hour}:00"
|
||||||
width: bar_width,
|
:daily -> format_date_label(entry.date)
|
||||||
height: max(bar_height, 1),
|
end
|
||||||
date: date,
|
|
||||||
visitors: visitors
|
%{height_pct: height_pct, label: label, visitors: visitors, index: i}
|
||||||
}
|
|
||||||
end)
|
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"""
|
~H"""
|
||||||
<div
|
<div
|
||||||
@ -190,28 +220,61 @@ defmodule BerrypodWeb.Admin.Analytics do
|
|||||||
>
|
>
|
||||||
No data for this period
|
No data for this period
|
||||||
</div>
|
</div>
|
||||||
<svg
|
<div :if={@data != []} style="display: grid; grid-template-columns: auto 1fr; gap: 0 0.5rem;">
|
||||||
:if={@data != []}
|
<%!-- Row 1: Y-axis labels + chart --%>
|
||||||
viewBox={"0 0 800 #{@chart_height}"}
|
<div
|
||||||
style="width: 100%; height: auto; max-height: 160px;"
|
class="analytics-y-labels"
|
||||||
aria-label="Visitor trend chart"
|
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}
|
:for={bar <- @bars}
|
||||||
x={bar.x}
|
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;"}
|
||||||
y={bar.y}
|
title={"#{bar.label}: #{bar.visitors} visitors"}
|
||||||
width={bar.width}
|
|
||||||
height={bar.height}
|
|
||||||
rx="2"
|
|
||||||
fill="var(--color-primary, #4f46e5)"
|
|
||||||
opacity="0.8"
|
|
||||||
>
|
>
|
||||||
<title>{bar.date}: {bar.visitors} visitors</title>
|
</div>
|
||||||
</rect>
|
</div>
|
||||||
</svg>
|
</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
|
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 ──
|
# ── Tab content ──
|
||||||
|
|
||||||
defp tab_content(%{tab: "pages"} = assigns) do
|
defp tab_content(%{tab: "pages"} = assigns) do
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user