add period comparison deltas to analytics stat cards
All checks were successful
deploy / deploy (push) Successful in 1m21s

Each stat card now shows the percentage change vs the equivalent
previous period (e.g. 30d compares last 30 days vs 30 days before).
Handles zero-baseline with "new" label and caps extreme deltas at
>999%. Seed data extended to 2 years for meaningful 12m comparisons.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jamey
2026-02-23 01:01:25 +00:00
parent 08fcd60eb6
commit 6eda1de1bc
5 changed files with 244 additions and 30 deletions

View File

@@ -39,17 +39,32 @@ defmodule BerrypodWeb.Admin.Analytics do
defp load_analytics(socket, period) do
range = date_range(period)
prev_range = previous_date_range(period)
trend_data =
if period == "today",
do: Analytics.visitors_by_hour(range),
else: Analytics.visitors_by_date(range)
visitors = Analytics.count_visitors(range)
pageviews = Analytics.count_pageviews(range)
bounce_rate = Analytics.bounce_rate(range)
avg_duration = Analytics.avg_duration(range)
prev_visitors = Analytics.count_visitors(prev_range)
prev_pageviews = Analytics.count_pageviews(prev_range)
prev_bounce_rate = Analytics.bounce_rate(prev_range)
prev_avg_duration = Analytics.avg_duration(prev_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, visitors)
|> assign(:pageviews, pageviews)
|> assign(:bounce_rate, bounce_rate)
|> assign(:avg_duration, avg_duration)
|> assign(:visitors_delta, compute_delta(visitors, prev_visitors))
|> assign(:pageviews_delta, compute_delta(pageviews, prev_pageviews))
|> assign(:bounce_rate_delta, compute_delta(bounce_rate, prev_bounce_rate))
|> assign(:avg_duration_delta, compute_delta(avg_duration, prev_avg_duration))
|> assign(:trend_data, trend_data)
|> assign(:trend_mode, if(period == "today", do: :hourly, else: :daily))
|> assign(:top_pages, Analytics.top_pages(range))
@@ -73,6 +88,21 @@ defmodule BerrypodWeb.Admin.Analytics do
DateTime.new!(end_date, ~T[00:00:00], "Etc/UTC")}
end
defp previous_date_range(period) do
days = Map.fetch!(@periods, period)
today = Date.utc_today()
duration = days + 1
current_start = Date.add(today, -days)
prev_start = Date.add(current_start, -duration)
{DateTime.new!(prev_start, ~T[00:00:00], "Etc/UTC"),
DateTime.new!(current_start, ~T[00:00:00], "Etc/UTC")}
end
defp compute_delta(0, 0), do: nil
defp compute_delta(_current, 0), do: :new
defp compute_delta(current, previous), do: round((current - previous) / previous * 100)
# ── Render ──
@impl true
@@ -94,10 +124,31 @@ defmodule BerrypodWeb.Admin.Analytics do
<%!-- Stat cards --%>
<div class="admin-stats-grid" style="margin-top: 1.5rem;">
<.stat_card label="Unique visitors" value={format_number(@visitors)} icon="hero-users" />
<.stat_card label="Total pageviews" value={format_number(@pageviews)} icon="hero-eye" />
<.stat_card label="Bounce rate" value={"#{@bounce_rate}%"} icon="hero-arrow-uturn-left" />
<.stat_card label="Visit duration" value={format_duration(@avg_duration)} icon="hero-clock" />
<.stat_card
label="Unique visitors"
value={format_number(@visitors)}
icon="hero-users"
delta={@visitors_delta}
/>
<.stat_card
label="Total pageviews"
value={format_number(@pageviews)}
icon="hero-eye"
delta={@pageviews_delta}
/>
<.stat_card
label="Bounce rate"
value={"#{@bounce_rate}%"}
icon="hero-arrow-uturn-left"
delta={@bounce_rate_delta}
invert
/>
<.stat_card
label="Visit duration"
value={format_duration(@avg_duration)}
icon="hero-clock"
delta={@avg_duration_delta}
/>
</div>
<%!-- Visitor trend chart --%>
@@ -140,6 +191,8 @@ defmodule BerrypodWeb.Admin.Analytics do
attr :label, :string, required: true
attr :value, :any, required: true
attr :icon, :string, required: true
attr :delta, :any, default: nil
attr :invert, :boolean, default: false
defp stat_card(assigns) do
~H"""
@@ -149,7 +202,10 @@ defmodule BerrypodWeb.Admin.Analytics do
<.icon name={@icon} class="size-5" />
</div>
<div>
<p style="font-size: 1.5rem; font-weight: 700;">{@value}</p>
<div style="display: flex; align-items: baseline; gap: 0.5rem;">
<p style="font-size: 1.5rem; font-weight: 700;">{@value}</p>
<.delta_badge :if={@delta != nil} delta={@delta} invert={@invert} />
</div>
<p style="font-size: 0.875rem; color: color-mix(in oklch, var(--color-base-content) 60%, transparent);">
{@label}
</p>
@@ -159,6 +215,36 @@ defmodule BerrypodWeb.Admin.Analytics do
"""
end
attr :delta, :any, required: true
attr :invert, :boolean, required: true
defp delta_badge(%{delta: :new} = assigns) do
~H"""
<span style="font-size: 0.75rem; font-weight: 500; color: color-mix(in oklch, var(--color-base-content) 50%, transparent); white-space: nowrap;">
new
</span>
"""
end
defp delta_badge(assigns) do
{color, arrow} =
cond do
assigns.delta > 0 && !assigns.invert -> {"var(--t-status-success, #22c55e)", ""}
assigns.delta > 0 && assigns.invert -> {"var(--t-status-error, #ef4444)", ""}
assigns.delta < 0 && !assigns.invert -> {"var(--t-status-error, #ef4444)", ""}
assigns.delta < 0 && assigns.invert -> {"var(--t-status-success, #22c55e)", ""}
true -> {"color-mix(in oklch, var(--color-base-content) 40%, transparent)", ""}
end
assigns = assign(assigns, color: color, arrow: arrow)
~H"""
<span style={"font-size: 0.75rem; font-weight: 500; color: #{@color}; white-space: nowrap;"}>
{@arrow} {if abs(@delta) > 999, do: ">999%", else: "#{abs(@delta)}%"}
</span>
"""
end
# ── Bar chart (HTML/CSS bars with readable labels) ──
attr :data, :list, required: true
@@ -190,18 +276,45 @@ defmodule BerrypodWeb.Admin.Analytics do
%{height_pct: height_pct, label: label, visitors: visitors, index: i}
end)
# X-axis labels: pick evenly spaced bars
x_label_count = if assigns.mode == :hourly, do: 6, else: min(bar_count, 5)
# X-axis labels
x_labels =
if bar_count <= x_label_count do
bars
else
step = bar_count / x_label_count
cond do
# 12m: show month names at month boundaries
bar_count > 60 ->
data
|> Enum.with_index()
|> Enum.chunk_by(fn {entry, _i} ->
date = parse_date(entry.date)
{date.year, date.month}
end)
|> Enum.map(fn [{entry, i} | _] ->
date = parse_date(entry.date)
%{label: Calendar.strftime(date, "%b"), index: i}
end)
# Skip first partial month if it starts mid-month
|> then(fn labels ->
case labels do
[first | rest] ->
date = parse_date(Enum.at(data, first.index).date)
if date.day > 7, do: rest, else: labels
Enum.filter(bars, fn bar ->
rem(bar.index, max(round(step), 1)) == 0
end)
_ ->
labels
end
end)
# Hourly: 6 evenly spaced labels
assigns.mode == :hourly ->
step = bar_count / 6
Enum.filter(bars, fn bar -> rem(bar.index, max(round(step), 1)) == 0 end)
# 30d: weekly intervals
bar_count > 14 ->
Enum.filter(bars, fn bar -> rem(bar.index, 7) == 0 end)
# 7d: every bar gets a label
true ->
bars
end
assigns =
@@ -220,7 +333,18 @@ defmodule BerrypodWeb.Admin.Analytics do
>
No data for this period
</div>
<div :if={@data != []} style="display: grid; grid-template-columns: auto 1fr; gap: 0 0.5rem;">
<div
:if={@data != []}
id="analytics-chart"
phx-hook="ChartTooltip"
style="display: grid; grid-template-columns: auto 1fr; gap: 0 0.5rem; position: relative;"
>
<%!-- Tooltip --%>
<div
data-tooltip
style="display: none; position: absolute; top: -1.75rem; z-index: 10; padding: 0.25rem 0.5rem; font-size: 0.75rem; font-weight: 500; white-space: nowrap; background: var(--color-base-content, #1e1e1e); color: var(--color-base-100, #fff); border-radius: 0.25rem; pointer-events: none; font-variant-numeric: tabular-nums;"
>
</div>
<%!-- Row 1: Y-axis labels + chart --%>
<div
class="analytics-y-labels"
@@ -237,11 +361,15 @@ defmodule BerrypodWeb.Admin.Analytics do
<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
data-bars
style={"display: flex; align-items: flex-end; height: 100%; gap: #{@bar_gap}px;"}
>
<div
:for={bar <- @bars}
data-label={bar.label}
data-visitors={bar.visitors}
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"}
>
</div>
</div>
@@ -267,6 +395,15 @@ defmodule BerrypodWeb.Admin.Analytics do
defp format_date_label(date), do: to_string(date)
defp parse_date(date) when is_binary(date) do
case Date.from_iso8601(date) do
{:ok, d} -> d
_ -> Date.utc_today()
end
end
defp parse_date(%Date{} = date), do: date
defp nice_max(0), do: 10
defp nice_max(val) do