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