From 6eda1de1bc0ff027bb081891984756667967f5a7 Mon Sep 17 00:00:00 2001 From: jamey Date: Mon, 23 Feb 2026 01:01:25 +0000 Subject: [PATCH] add period comparison deltas to analytics stat cards 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 --- assets/css/admin/components.css | 4 + assets/js/app.js | 75 +++++++- lib/berrypod_web/live/admin/analytics.ex | 181 +++++++++++++++--- priv/repo/seeds/analytics.exs | 12 +- .../live/admin/analytics_test.exs | 2 +- 5 files changed, 244 insertions(+), 30 deletions(-) diff --git a/assets/css/admin/components.css b/assets/css/admin/components.css index 8d4fabe..e6cf94b 100644 --- a/assets/css/admin/components.css +++ b/assets/css/admin/components.css @@ -671,6 +671,10 @@ font-variant-numeric: tabular-nums; } +[data-bars] > div { + cursor: crosshair; +} + /* ── Setup page ── */ .setup-page { diff --git a/assets/js/app.js b/assets/js/app.js index e3f2ec0..51a24ca 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -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 diff --git a/lib/berrypod_web/live/admin/analytics.ex b/lib/berrypod_web/live/admin/analytics.ex index 4677c8b..b018eed 100644 --- a/lib/berrypod_web/live/admin/analytics.ex +++ b/lib/berrypod_web/live/admin/analytics.ex @@ -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 --%>
- <.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} + />
<%!-- 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" />
-

{@value}

+
+

{@value}

+ <.delta_badge :if={@delta != nil} delta={@delta} invert={@invert} /> +

{@label}

@@ -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""" + + new + + """ + 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""" + + {@arrow} {if abs(@delta) > 999, do: ">999%", else: "#{abs(@delta)}%"} + + """ + 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
-
+
+ <%!-- Tooltip --%> +
+
<%!-- Row 1: Y-axis labels + chart --%>
<%!-- Bars container --%> -
+
@@ -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 diff --git a/priv/repo/seeds/analytics.exs b/priv/repo/seeds/analytics.exs index d04e0de..71c486b 100644 --- a/priv/repo/seeds/analytics.exs +++ b/priv/repo/seeds/analytics.exs @@ -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) diff --git a/test/berrypod_web/live/admin/analytics_test.exs b/test/berrypod_web/live/admin/analytics_test.exs index 964668d..67a6836 100644 --- a/test/berrypod_web/live/admin/analytics_test.exs +++ b/test/berrypod_web/live/admin/analytics_test.exs @@ -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