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:
parent
08fcd60eb6
commit
6eda1de1bc
@ -671,6 +671,10 @@
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
[data-bars] > div {
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
/* ── Setup page ── */
|
||||
|
||||
.setup-page {
|
||||
|
||||
@ -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 liveSocket = new LiveSocket("/live", Socket, {
|
||||
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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
#
|
||||
# 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.
|
||||
|
||||
alias Berrypod.Repo
|
||||
@ -13,9 +13,9 @@ alias Berrypod.Analytics.Event
|
||||
# How many unique "visitors" to simulate per day (base — actual varies by day)
|
||||
base_daily_visitors = 40
|
||||
|
||||
# Date range: 12 months back from today
|
||||
# Date range: 2 years back from today
|
||||
end_date = Date.utc_today()
|
||||
start_date = Date.add(end_date, -364)
|
||||
start_date = Date.add(end_date, -729)
|
||||
|
||||
# ── Reference data ──
|
||||
|
||||
@ -150,8 +150,8 @@ end
|
||||
# Monthly growth curve — traffic grows over time (new shop ramping up)
|
||||
month_multiplier = fn date ->
|
||||
months_ago = Date.diff(end_date, date) / 30.0
|
||||
# Start at 0.3x and grow to 1.0x over the year
|
||||
max(0.3, 1.0 - months_ago * 0.06)
|
||||
# Start at 0.2x two years ago and grow to 1.0x now
|
||||
max(0.2, 1.0 - months_ago * 0.035)
|
||||
end
|
||||
|
||||
# Seasonal bumps (Nov-Dec holiday shopping, Jan sale)
|
||||
|
||||
@ -62,7 +62,7 @@ defmodule BerrypodWeb.Admin.AnalyticsTest do
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/analytics")
|
||||
|
||||
assert has_element?(view, "rect")
|
||||
assert has_element?(view, "[data-bars]")
|
||||
end
|
||||
|
||||
test "changes period", %{conn: conn} do
|
||||
|
||||
Loading…
Reference in New Issue
Block a user