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

@ -671,6 +671,10 @@
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
[data-bars] > div {
cursor: crosshair;
}
/* ── Setup page ── */ /* ── Setup page ── */
.setup-page { .setup-page {

View File

@ -516,10 +516,83 @@ const AnalyticsInit = {
} }
} }
// Analytics: chart tooltip on hover/tap
const ChartTooltip = {
mounted() { this._setup() },
updated() { this._setup() },
_setup() {
// Re-query after LiveView patches
this.tooltip = this.el.querySelector("[data-tooltip]")
this.bars = this.el.querySelector("[data-bars]")
if (!this.tooltip || !this.bars) return
// Clean up previous listeners if re-setting up
if (this._cleanup) this._cleanup()
const onMove = (e) => {
const clientX = e.touches ? e.touches[0].clientX : e.clientX
const bar = this._barAt(clientX)
if (bar) this._show(bar, clientX)
}
const onLeave = () => this._hide()
const onDocTap = (e) => {
if (!this.el.contains(e.target)) this._hide()
}
this.bars.addEventListener("mousemove", onMove)
this.bars.addEventListener("mouseleave", onLeave)
this.bars.addEventListener("touchstart", onMove, { passive: true })
document.addEventListener("touchstart", onDocTap, { passive: true })
this._cleanup = () => {
this.bars.removeEventListener("mousemove", onMove)
this.bars.removeEventListener("mouseleave", onLeave)
this.bars.removeEventListener("touchstart", onMove)
document.removeEventListener("touchstart", onDocTap)
}
},
destroyed() {
if (this._cleanup) this._cleanup()
},
_barAt(clientX) {
const children = this.bars.children
for (let i = 0; i < children.length; i++) {
const rect = children[i].getBoundingClientRect()
if (clientX >= rect.left && clientX <= rect.right) return children[i]
}
return null
},
_show(bar, clientX) {
const label = bar.dataset.label
const visitors = bar.dataset.visitors
if (!label) return
this.tooltip.textContent = `${label}: ${visitors} visitor${visitors === "1" ? "" : "s"}`
this.tooltip.style.display = "block"
// Position: centered on cursor, clamped to chart bounds
const chartRect = this.el.getBoundingClientRect()
const tipWidth = this.tooltip.offsetWidth
let left = clientX - chartRect.left - tipWidth / 2
left = Math.max(0, Math.min(left, chartRect.width - tipWidth))
this.tooltip.style.left = left + "px"
},
_hide() {
if (this.tooltip) this.tooltip.style.display = "none"
}
}
const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
const liveSocket = new LiveSocket("/live", Socket, { const liveSocket = new LiveSocket("/live", Socket, {
params: {_csrf_token: csrfToken}, params: {_csrf_token: csrfToken},
hooks: {...colocatedHooks, ColorSync, Lightbox, CartPersist, CartDrawer, ProductImageScroll, SearchModal, CollectionFilters, CardRadioScroll, AnalyticsInit}, hooks: {...colocatedHooks, ColorSync, Lightbox, CartPersist, CartDrawer, ProductImageScroll, SearchModal, CollectionFilters, CardRadioScroll, AnalyticsInit, ChartTooltip},
}) })
// Show progress bar on live navigation and form submits // Show progress bar on live navigation and form submits

View File

@ -39,17 +39,32 @@ 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)
prev_range = previous_date_range(period)
trend_data = trend_data =
if period == "today", if period == "today",
do: Analytics.visitors_by_hour(range), do: Analytics.visitors_by_hour(range),
else: Analytics.visitors_by_date(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 socket
|> assign(:visitors, Analytics.count_visitors(range)) |> assign(:visitors, visitors)
|> assign(:pageviews, Analytics.count_pageviews(range)) |> assign(:pageviews, pageviews)
|> assign(:bounce_rate, Analytics.bounce_rate(range)) |> assign(:bounce_rate, bounce_rate)
|> assign(:avg_duration, Analytics.avg_duration(range)) |> 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_data, trend_data)
|> assign(:trend_mode, if(period == "today", do: :hourly, else: :daily)) |> assign(:trend_mode, if(period == "today", do: :hourly, else: :daily))
|> assign(:top_pages, Analytics.top_pages(range)) |> 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")} DateTime.new!(end_date, ~T[00:00:00], "Etc/UTC")}
end 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 ── # ── Render ──
@impl true @impl true
@ -94,10 +124,31 @@ defmodule BerrypodWeb.Admin.Analytics do
<%!-- Stat cards --%> <%!-- Stat cards --%>
<div class="admin-stats-grid" style="margin-top: 1.5rem;"> <div class="admin-stats-grid" style="margin-top: 1.5rem;">
<.stat_card label="Unique visitors" value={format_number(@visitors)} icon="hero-users" /> <.stat_card
<.stat_card label="Total pageviews" value={format_number(@pageviews)} icon="hero-eye" /> label="Unique visitors"
<.stat_card label="Bounce rate" value={"#{@bounce_rate}%"} icon="hero-arrow-uturn-left" /> value={format_number(@visitors)}
<.stat_card label="Visit duration" value={format_duration(@avg_duration)} icon="hero-clock" /> 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> </div>
<%!-- Visitor trend chart --%> <%!-- Visitor trend chart --%>
@ -140,6 +191,8 @@ defmodule BerrypodWeb.Admin.Analytics do
attr :label, :string, required: true attr :label, :string, required: true
attr :value, :any, required: true attr :value, :any, required: true
attr :icon, :string, required: true attr :icon, :string, required: true
attr :delta, :any, default: nil
attr :invert, :boolean, default: false
defp stat_card(assigns) do defp stat_card(assigns) do
~H""" ~H"""
@ -149,7 +202,10 @@ defmodule BerrypodWeb.Admin.Analytics do
<.icon name={@icon} class="size-5" /> <.icon name={@icon} class="size-5" />
</div> </div>
<div> <div>
<div style="display: flex; align-items: baseline; gap: 0.5rem;">
<p style="font-size: 1.5rem; font-weight: 700;">{@value}</p> <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);"> <p style="font-size: 0.875rem; color: color-mix(in oklch, var(--color-base-content) 60%, transparent);">
{@label} {@label}
</p> </p>
@ -159,6 +215,36 @@ defmodule BerrypodWeb.Admin.Analytics do
""" """
end 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) ── # ── Bar chart (HTML/CSS bars with readable labels) ──
attr :data, :list, required: true attr :data, :list, required: true
@ -190,18 +276,45 @@ defmodule BerrypodWeb.Admin.Analytics do
%{height_pct: height_pct, label: label, visitors: visitors, index: i} %{height_pct: height_pct, label: label, visitors: visitors, index: i}
end) end)
# X-axis labels: pick evenly spaced bars # X-axis labels
x_label_count = if assigns.mode == :hourly, do: 6, else: min(bar_count, 5)
x_labels = x_labels =
if bar_count <= x_label_count do cond do
bars # 12m: show month names at month boundaries
else bar_count > 60 ->
step = bar_count / x_label_count data
|> Enum.with_index()
Enum.filter(bars, fn bar -> |> Enum.chunk_by(fn {entry, _i} ->
rem(bar.index, max(round(step), 1)) == 0 date = parse_date(entry.date)
{date.year, date.month}
end) 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
_ ->
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 end
assigns = assigns =
@ -220,7 +333,18 @@ defmodule BerrypodWeb.Admin.Analytics do
> >
No data for this period No data for this period
</div> </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 --%> <%!-- Row 1: Y-axis labels + chart --%>
<div <div
class="analytics-y-labels" 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 style="position: absolute; bottom: 0; left: 0; right: 0; border-top: 1px solid color-mix(in oklch, var(--color-base-content) 15%, transparent);">
</div> </div>
<%!-- Bars container --%> <%!-- 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 <div
:for={bar <- @bars} :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;"} 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>
</div> </div>
@ -267,6 +395,15 @@ defmodule BerrypodWeb.Admin.Analytics do
defp format_date_label(date), do: to_string(date) 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(0), do: 10
defp nice_max(val) do defp nice_max(val) do

View File

@ -1,8 +1,8 @@
# Generates realistic analytics demo data spanning 12 months. # Generates realistic analytics demo data spanning 2 years.
# #
# mix run priv/repo/seeds/analytics.exs # mix run priv/repo/seeds/analytics.exs
# #
# Clears existing analytics events first, then creates ~50k events with # Clears existing analytics events first, then creates ~90k events with
# realistic traffic patterns, referrers, device mix, and e-commerce funnel. # realistic traffic patterns, referrers, device mix, and e-commerce funnel.
alias Berrypod.Repo alias Berrypod.Repo
@ -13,9 +13,9 @@ alias Berrypod.Analytics.Event
# How many unique "visitors" to simulate per day (base — actual varies by day) # How many unique "visitors" to simulate per day (base — actual varies by day)
base_daily_visitors = 40 base_daily_visitors = 40
# Date range: 12 months back from today # Date range: 2 years back from today
end_date = Date.utc_today() end_date = Date.utc_today()
start_date = Date.add(end_date, -364) start_date = Date.add(end_date, -729)
# ── Reference data ── # ── Reference data ──
@ -150,8 +150,8 @@ end
# Monthly growth curve — traffic grows over time (new shop ramping up) # Monthly growth curve — traffic grows over time (new shop ramping up)
month_multiplier = fn date -> month_multiplier = fn date ->
months_ago = Date.diff(end_date, date) / 30.0 months_ago = Date.diff(end_date, date) / 30.0
# Start at 0.3x and grow to 1.0x over the year # Start at 0.2x two years ago and grow to 1.0x now
max(0.3, 1.0 - months_ago * 0.06) max(0.2, 1.0 - months_ago * 0.035)
end end
# Seasonal bumps (Nov-Dec holiday shopping, Jan sale) # Seasonal bumps (Nov-Dec holiday shopping, Jan sale)

View File

@ -62,7 +62,7 @@ defmodule BerrypodWeb.Admin.AnalyticsTest do
{:ok, view, _html} = live(conn, ~p"/admin/analytics") {:ok, view, _html} = live(conn, ~p"/admin/analytics")
assert has_element?(view, "rect") assert has_element?(view, "[data-bars]")
end end
test "changes period", %{conn: conn} do test "changes period", %{conn: conn} do