add period comparison deltas to analytics stat cards
All checks were successful
deploy / deploy (push) Successful in 1m21s
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user