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 --%>
{@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