diff --git a/PROGRESS.md b/PROGRESS.md
index a8c4995..232b00c 100644
--- a/PROGRESS.md
+++ b/PROGRESS.md
@@ -9,7 +9,7 @@
- Image optimization pipeline (AVIF/WebP/JPEG responsive variants)
- Shop pages (home, collections, products, cart, about, contact, error, delivery, privacy, terms)
- Mobile-first design with bottom navigation
-- 898 tests passing, 100% PageSpeed score
+- 972 tests passing, 100% PageSpeed score
- SQLite production tuning (IMMEDIATE transactions, mmap, WAL journal limit)
- Variant selector with color swatches and size buttons
- Session-based cart with real variant data (add/remove/quantity, cross-tab sync)
@@ -61,7 +61,7 @@ Plans: [admin-redesign.md](docs/plans/admin-redesign.md) | [admin-font-loading.m
| ~~29~~ | ~~Printful webhooks~~ | 25 | 1.5h | done |
| | **Next up** | | | |
| ~~30~~ | ~~Admin UI tweaks for Printful~~ | 25 | 30m | done |
-| 31 | Printful tests + integration testing | 24-30 | 4.5h | |
+| ~~31~~ | ~~Printful + Printify client tests with Req.Test stubs~~ | 24-30 | 4.5h | done |
| | **Setup and launch readiness** ([plan](docs/plans/setup-and-launch.md)) | | | |
| ~~41~~ | ~~Provider + payment registries~~ | — | 30m | done |
| ~~42~~ | ~~Make Setup provider-agnostic + add checklist fields~~ | 41 | 45m | done |
@@ -87,7 +87,7 @@ Plans: [admin-redesign.md](docs/plans/admin-redesign.md) | [admin-font-loading.m
| | **Bugs / polish** | | | |
| ~~49~~ | ~~Admin font loading + cache miss path resolver ([plan](docs/plans/admin-font-loading.md))~~ | — | 1h | done |
-**Total remaining: ~4.5 hours** (Printful tests)
+**All tasks complete.** No remaining work in the task list.
See [css-migration.md](docs/plans/css-migration.md) for full plan with architecture, visual regression testing strategy, and acceptance criteria per phase.
@@ -374,7 +374,7 @@ See: [plan](docs/plans/shipping-sync.md) for implementation details
- [x] `mix bench.sqlite` task with `--prod`, `--scale`, `--pool-size`, `--busy-timeout` options
- [x] PRAGMA tuning across dev/test/prod: `default_transaction_mode: :immediate`, `journal_size_limit: 64MB`, `custom_pragmas: [mmap_size: 128MB]`
- [x] Benchmarks confirmed: IMMEDIATE mode eliminates transaction upgrade BUSY errors (0% vs 73-80% failure rate under contention), prod mode 5-12x faster than dev, 300 concurrent mixed requests with zero errors
-- [x] 898 tests total (6 correctness + 3 benchmarks excluded by default)
+- [x] 972 tests total (5 excluded: 3 benchmarks + 2 correctness)
### Page Editor
**Status:** Future (Tier 4)
@@ -389,6 +389,7 @@ See: [docs/plans/page-builder.md](docs/plans/page-builder.md) for design
| Feature | Commit | Notes |
|---------|--------|-------|
+| Printify + Printful client tests | b0aed4c, a45e85e | Req.Test stubs for both HTTP clients, provider integration tests, mockup enricher tests, 972 tests |
| SQLite production tuning | 162bf4c, 19d8c7d | Concurrency tests, `mix bench.sqlite` task, IMMEDIATE transactions, mmap 128MB, journal_size_limit 64MB, 898 tests |
| Per-colour images + gallery filtering | 0fe48ba | colour column on product_images, per-colour mockup enrichment, PDP gallery filtering, Printify option filtering, hero colour ordering, 821 tests |
| Printful catalog colours | 4e19d4c | Fetch hex codes from catalog product API during sync, cached per catalog_product_id |
diff --git a/assets/js/app.js b/assets/js/app.js
index 7a3c043..e3f2ec0 100644
--- a/assets/js/app.js
+++ b/assets/js/app.js
@@ -509,10 +509,17 @@ const CardRadioScroll = {
}
}
+// Analytics: send screen width for device classification (Layer 3)
+const AnalyticsInit = {
+ mounted() {
+ this.pushEvent("analytics:screen", { width: window.innerWidth })
+ }
+}
+
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},
+ hooks: {...colocatedHooks, ColorSync, Lightbox, CartPersist, CartDrawer, ProductImageScroll, SearchModal, CollectionFilters, CardRadioScroll, AnalyticsInit},
})
// Show progress bar on live navigation and form submits
diff --git a/config/config.exs b/config/config.exs
index 37c82e3..0b6e012 100644
--- a/config/config.exs
+++ b/config/config.exs
@@ -93,7 +93,8 @@ config :berrypod, Oban,
{Oban.Plugins.Cron,
crontab: [
{"*/30 * * * *", Berrypod.Orders.FulfilmentStatusWorker},
- {"0 */6 * * *", Berrypod.Sync.ScheduledSyncWorker}
+ {"0 */6 * * *", Berrypod.Sync.ScheduledSyncWorker},
+ {"0 3 * * *", Berrypod.Analytics.RetentionWorker}
]}
],
queues: [images: 2, sync: 1, checkout: 1]
diff --git a/lib/berrypod/analytics.ex b/lib/berrypod/analytics.ex
new file mode 100644
index 0000000..2fda196
--- /dev/null
+++ b/lib/berrypod/analytics.ex
@@ -0,0 +1,284 @@
+defmodule Berrypod.Analytics do
+ @moduledoc """
+ Privacy-first analytics for the storefront.
+
+ Inspired by Plausible Analytics: no cookies, no personal data stored,
+ GDPR-friendly by default. Events are buffered in ETS and flushed to
+ SQLite in batches.
+
+ ## Event types
+
+ - `pageview` — recorded by the plug (Layer 1) and LiveView hook (Layer 2)
+ - `product_view` — recorded when a product detail page is viewed
+ - `add_to_cart` — recorded when an item is added to the cart
+ - `checkout_start` — recorded when checkout is initiated
+ - `purchase` — recorded when a purchase is completed (includes revenue)
+ """
+
+ import Ecto.Query
+
+ alias Berrypod.Repo
+ alias Berrypod.Analytics.{Buffer, Event}
+
+ # =====================================================================
+ # Event recording
+ # =====================================================================
+
+ @doc """
+ Records a pageview event via the buffer.
+
+ Expects a map with at minimum `:pathname` and `:visitor_hash`.
+ Other fields (referrer, UTMs, browser, os, country_code, screen_size)
+ are optional.
+ """
+ def track_pageview(attrs) when is_map(attrs) do
+ attrs
+ |> Map.put(:name, "pageview")
+ |> Buffer.record()
+ end
+
+ @doc """
+ Records a named event via the buffer.
+
+ The `name` should be one of: `product_view`, `add_to_cart`,
+ `checkout_start`, `purchase`.
+ """
+ def track_event(name, attrs) when is_binary(name) and is_map(attrs) do
+ attrs
+ |> Map.put(:name, name)
+ |> Buffer.record()
+ end
+
+ # =====================================================================
+ # Query helpers
+ # =====================================================================
+
+ @doc """
+ Counts unique visitors in the given date range.
+ """
+ def count_visitors(date_range) do
+ base_query(date_range)
+ |> where([e], e.name == "pageview")
+ |> select([e], count(e.visitor_hash, :distinct))
+ |> Repo.one()
+ end
+
+ @doc """
+ Counts total pageviews in the given date range.
+ """
+ def count_pageviews(date_range) do
+ base_query(date_range)
+ |> where([e], e.name == "pageview")
+ |> select([e], count())
+ |> Repo.one()
+ end
+
+ @doc """
+ Calculates bounce rate as a percentage (0-100).
+
+ Bounce = a session with only one pageview.
+ """
+ def bounce_rate(date_range) do
+ sessions_query =
+ base_query(date_range)
+ |> where([e], e.name == "pageview")
+ |> group_by([e], e.session_hash)
+ |> select([e], %{
+ session_hash: e.session_hash,
+ pageviews: count()
+ })
+
+ result =
+ from(s in subquery(sessions_query),
+ select: %{
+ total: count(),
+ bounces: sum(fragment("CASE WHEN ? = 1 THEN 1 ELSE 0 END", s.pageviews))
+ }
+ )
+ |> Repo.one()
+
+ case result do
+ %{total: 0} -> 0
+ %{total: nil} -> 0
+ %{total: total, bounces: bounces} -> round(bounces / total * 100)
+ end
+ end
+
+ @doc """
+ Average visit duration in seconds.
+ """
+ def avg_duration(date_range) do
+ durations_query =
+ base_query(date_range)
+ |> where([e], e.name == "pageview")
+ |> group_by([e], e.session_hash)
+ |> having([e], count() > 1)
+ |> select([e], %{
+ duration:
+ fragment(
+ "CAST(strftime('%s', MAX(?)) AS INTEGER) - CAST(strftime('%s', MIN(?)) AS INTEGER)",
+ e.inserted_at,
+ e.inserted_at
+ )
+ })
+
+ result =
+ from(d in subquery(durations_query),
+ select: fragment("COALESCE(AVG(?), 0)", d.duration)
+ )
+ |> Repo.one()
+
+ round(result)
+ end
+
+ @doc """
+ Daily visitor counts for the trend chart.
+
+ Returns a list of `%{date: ~D[], visitors: integer}` maps.
+ """
+ def visitors_by_date(date_range) do
+ base_query(date_range)
+ |> where([e], e.name == "pageview")
+ |> group_by([e], fragment("date(?)", e.inserted_at))
+ |> select([e], %{
+ date: fragment("date(?)", e.inserted_at),
+ visitors: count(e.visitor_hash, :distinct)
+ })
+ |> order_by([e], fragment("date(?)", e.inserted_at))
+ |> Repo.all()
+ end
+
+ @doc """
+ Top pages by unique visitors.
+ """
+ def top_pages(date_range, limit \\ 10) do
+ base_query(date_range)
+ |> where([e], e.name == "pageview")
+ |> group_by([e], e.pathname)
+ |> select([e], %{
+ pathname: e.pathname,
+ visitors: count(e.visitor_hash, :distinct),
+ pageviews: count()
+ })
+ |> order_by([e], desc: count(e.visitor_hash, :distinct))
+ |> limit(^limit)
+ |> Repo.all()
+ end
+
+ @doc """
+ Top referrer sources by unique visitors.
+ """
+ def top_sources(date_range, limit \\ 10) do
+ base_query(date_range)
+ |> where([e], e.name == "pageview")
+ |> where([e], not is_nil(e.referrer_source))
+ |> group_by([e], e.referrer_source)
+ |> select([e], %{
+ source: e.referrer_source,
+ visitors: count(e.visitor_hash, :distinct)
+ })
+ |> order_by([e], desc: count(e.visitor_hash, :distinct))
+ |> limit(^limit)
+ |> Repo.all()
+ end
+
+ @doc """
+ Top referrer domains by unique visitors.
+ """
+ def top_referrers(date_range, limit \\ 10) do
+ base_query(date_range)
+ |> where([e], e.name == "pageview")
+ |> where([e], not is_nil(e.referrer))
+ |> group_by([e], e.referrer)
+ |> select([e], %{
+ referrer: e.referrer,
+ visitors: count(e.visitor_hash, :distinct)
+ })
+ |> order_by([e], desc: count(e.visitor_hash, :distinct))
+ |> limit(^limit)
+ |> Repo.all()
+ end
+
+ @doc """
+ Country breakdown by unique visitors.
+ """
+ def top_countries(date_range, limit \\ 10) do
+ base_query(date_range)
+ |> where([e], e.name == "pageview")
+ |> where([e], not is_nil(e.country_code))
+ |> group_by([e], e.country_code)
+ |> select([e], %{
+ country_code: e.country_code,
+ visitors: count(e.visitor_hash, :distinct)
+ })
+ |> order_by([e], desc: count(e.visitor_hash, :distinct))
+ |> limit(^limit)
+ |> Repo.all()
+ end
+
+ @doc """
+ Device breakdown by the given dimension (:browser, :os, or :screen_size).
+ """
+ def device_breakdown(date_range, dimension) when dimension in [:browser, :os, :screen_size] do
+ field = dimension
+
+ base_query(date_range)
+ |> where([e], e.name == "pageview")
+ |> where([e], not is_nil(field(e, ^field)))
+ |> group_by([e], field(e, ^field))
+ |> select([e], %{
+ name: field(e, ^field),
+ visitors: count(e.visitor_hash, :distinct)
+ })
+ |> order_by([e], desc: count(e.visitor_hash, :distinct))
+ |> Repo.all()
+ end
+
+ @doc """
+ E-commerce funnel: counts for each step.
+
+ Returns `%{product_views: n, add_to_carts: n, checkouts: n, purchases: n}`.
+ """
+ def funnel(date_range) do
+ counts =
+ base_query(date_range)
+ |> where([e], e.name in ["product_view", "add_to_cart", "checkout_start", "purchase"])
+ |> group_by([e], e.name)
+ |> select([e], {e.name, count(e.visitor_hash, :distinct)})
+ |> Repo.all()
+ |> Map.new()
+
+ %{
+ product_views: Map.get(counts, "product_view", 0),
+ add_to_carts: Map.get(counts, "add_to_cart", 0),
+ checkouts: Map.get(counts, "checkout_start", 0),
+ purchases: Map.get(counts, "purchase", 0)
+ }
+ end
+
+ @doc """
+ Total revenue in the given date range (pence).
+ """
+ def total_revenue(date_range) do
+ base_query(date_range)
+ |> where([e], e.name == "purchase")
+ |> select([e], coalesce(sum(e.revenue), 0))
+ |> Repo.one()
+ end
+
+ @doc """
+ Deletes events older than the given datetime. Used by the retention worker.
+ """
+ def delete_events_before(datetime) do
+ from(e in Event, where: e.inserted_at < ^datetime)
+ |> Repo.delete_all()
+ end
+
+ # ── Private ──
+
+ defp base_query({start_date, end_date}) do
+ from(e in Event,
+ where: e.inserted_at >= ^start_date and e.inserted_at < ^end_date
+ )
+ end
+end
diff --git a/lib/berrypod/analytics/buffer.ex b/lib/berrypod/analytics/buffer.ex
new file mode 100644
index 0000000..f3e549f
--- /dev/null
+++ b/lib/berrypod/analytics/buffer.ex
@@ -0,0 +1,151 @@
+defmodule Berrypod.Analytics.Buffer do
+ @moduledoc """
+ ETS-backed event buffer for analytics.
+
+ Events are written to ETS via `record/1` (fast, no DB round trip) and
+ flushed to SQLite every 10 seconds in a single `Repo.insert_all` call.
+ This prevents write contention on SQLite from individual event inserts.
+
+ Also tracks active sessions: events from the same visitor within 30 minutes
+ get the same `session_hash`. After 30 min of inactivity, a new session starts.
+ """
+
+ use GenServer
+
+ alias Berrypod.Repo
+ alias Berrypod.Analytics.Event
+
+ @flush_interval_ms 10_000
+ @session_timeout_ms 30 * 60 * 1_000
+
+ @event_fields [
+ :id,
+ :name,
+ :pathname,
+ :visitor_hash,
+ :session_hash,
+ :referrer,
+ :referrer_source,
+ :utm_source,
+ :utm_medium,
+ :utm_campaign,
+ :country_code,
+ :screen_size,
+ :browser,
+ :os,
+ :revenue,
+ :inserted_at
+ ]
+ @event_defaults Map.new(@event_fields, &{&1, nil})
+
+ # ── Public API ──
+
+ def start_link(opts \\ []) do
+ GenServer.start_link(__MODULE__, opts, name: __MODULE__)
+ end
+
+ @doc """
+ Buffers an analytics event for batch writing.
+
+ Accepts a map of event attributes (must include `:visitor_hash` and `:pathname`).
+ Assigns a `session_hash` based on the visitor's active session.
+ Returns `:ok` immediately.
+ """
+ def record(attrs) when is_map(attrs) do
+ GenServer.cast(__MODULE__, {:record, attrs})
+ end
+
+ # ── GenServer callbacks ──
+
+ @impl true
+ def init(_opts) do
+ table = :ets.new(:analytics_buffer, [:set, :private])
+ schedule_flush()
+
+ {:ok, %{table: table, counter: 0, sessions: %{}}}
+ end
+
+ @impl true
+ def handle_cast({:record, attrs}, state) do
+ now = DateTime.utc_now()
+ visitor_hash = Map.fetch!(attrs, :visitor_hash)
+
+ {session_hash, sessions} = resolve_session(visitor_hash, now, state.sessions)
+
+ event =
+ attrs
+ |> Map.put(:session_hash, session_hash)
+ |> Map.put(:id, Ecto.UUID.generate())
+ |> Map.put(:inserted_at, DateTime.truncate(now, :second))
+
+ counter = state.counter + 1
+ :ets.insert(state.table, {counter, event})
+
+ {:noreply, %{state | counter: counter, sessions: sessions}}
+ end
+
+ @impl true
+ def handle_info(:flush, state) do
+ state = flush_events(state)
+ schedule_flush()
+ {:noreply, state}
+ end
+
+ @impl true
+ def terminate(_reason, state) do
+ flush_events(state)
+ :ok
+ end
+
+ # ── Private ──
+
+ defp resolve_session(visitor_hash, now, sessions) do
+ now_ms = DateTime.to_unix(now, :millisecond)
+
+ case Map.get(sessions, visitor_hash) do
+ %{hash: hash, last_at: last_at} when now_ms - last_at < @session_timeout_ms ->
+ sessions = Map.put(sessions, visitor_hash, %{hash: hash, last_at: now_ms})
+ {hash, sessions}
+
+ _ ->
+ hash = :crypto.strong_rand_bytes(8)
+ sessions = Map.put(sessions, visitor_hash, %{hash: hash, last_at: now_ms})
+ {hash, sessions}
+ end
+ end
+
+ defp flush_events(state) do
+ events = drain_ets(state.table)
+
+ if events != [] do
+ rows =
+ Enum.map(events, fn {_key, event} ->
+ @event_defaults
+ |> Map.merge(Map.take(event, @event_fields))
+ |> Enum.into([])
+ end)
+
+ Repo.insert_all(Event, rows)
+ end
+
+ # Prune expired sessions
+ now_ms = DateTime.to_unix(DateTime.utc_now(), :millisecond)
+
+ sessions =
+ Map.filter(state.sessions, fn {_hash, %{last_at: last_at}} ->
+ now_ms - last_at < @session_timeout_ms
+ end)
+
+ %{state | counter: 0, sessions: sessions}
+ end
+
+ defp drain_ets(table) do
+ events = :ets.tab2list(table)
+ :ets.delete_all_objects(table)
+ events
+ end
+
+ defp schedule_flush do
+ Process.send_after(self(), :flush, @flush_interval_ms)
+ end
+end
diff --git a/lib/berrypod/analytics/event.ex b/lib/berrypod/analytics/event.ex
new file mode 100644
index 0000000..49be1ce
--- /dev/null
+++ b/lib/berrypod/analytics/event.ex
@@ -0,0 +1,30 @@
+defmodule Berrypod.Analytics.Event do
+ @moduledoc """
+ Schema for analytics events (pageviews, e-commerce events, etc.).
+
+ Events are immutable — inserted once, never updated.
+ """
+
+ use Ecto.Schema
+
+ @primary_key {:id, :binary_id, autogenerate: true}
+
+ schema "analytics_events" do
+ field :name, :string
+ field :pathname, :string
+ field :visitor_hash, :binary
+ field :session_hash, :binary
+ field :referrer, :string
+ field :referrer_source, :string
+ field :utm_source, :string
+ field :utm_medium, :string
+ field :utm_campaign, :string
+ field :country_code, :string
+ field :screen_size, :string
+ field :browser, :string
+ field :os, :string
+ field :revenue, :integer
+
+ timestamps(type: :utc_datetime, updated_at: false)
+ end
+end
diff --git a/lib/berrypod/analytics/referrer.ex b/lib/berrypod/analytics/referrer.ex
new file mode 100644
index 0000000..0fa5a44
--- /dev/null
+++ b/lib/berrypod/analytics/referrer.ex
@@ -0,0 +1,72 @@
+defmodule Berrypod.Analytics.Referrer do
+ @moduledoc """
+ Referrer cleaning and source categorisation.
+
+ Extracts the domain from a referrer URL and maps known domains to
+ human-readable source names (Google, Facebook, etc.).
+ """
+
+ @source_map %{
+ "google" => "Google",
+ "bing" => "Bing",
+ "duckduckgo" => "DuckDuckGo",
+ "yahoo" => "Yahoo",
+ "baidu" => "Baidu",
+ "yandex" => "Yandex",
+ "ecosia" => "Ecosia",
+ "facebook" => "Facebook",
+ "instagram" => "Instagram",
+ "twitter" => "Twitter",
+ "x" => "Twitter",
+ "reddit" => "Reddit",
+ "linkedin" => "LinkedIn",
+ "pinterest" => "Pinterest",
+ "tiktok" => "TikTok",
+ "youtube" => "YouTube",
+ "t" => "Telegram"
+ }
+
+ @doc """
+ Parses a referrer URL into `{domain, source}`.
+
+ Returns `{nil, nil}` for empty/invalid referrers.
+
+ iex> Berrypod.Analytics.Referrer.parse("https://www.google.com/search?q=test")
+ {"google.com", "Google"}
+
+ iex> Berrypod.Analytics.Referrer.parse("https://myblog.example.com/post/1")
+ {"myblog.example.com", nil}
+
+ iex> Berrypod.Analytics.Referrer.parse(nil)
+ {nil, nil}
+
+ """
+ @spec parse(String.t() | nil) :: {String.t() | nil, String.t() | nil}
+ def parse(nil), do: {nil, nil}
+ def parse(""), do: {nil, nil}
+
+ def parse(referrer) when is_binary(referrer) do
+ case URI.parse(referrer) do
+ %URI{host: host} when is_binary(host) and host != "" ->
+ domain = strip_www(host)
+ source = categorise(domain)
+ {domain, source}
+
+ _ ->
+ {nil, nil}
+ end
+ end
+
+ defp strip_www("www." <> rest), do: rest
+ defp strip_www(host), do: host
+
+ defp categorise(domain) do
+ # Extract the base name from the domain (e.g. "google" from "google.co.uk")
+ base =
+ domain
+ |> String.split(".")
+ |> List.first()
+
+ Map.get(@source_map, base)
+ end
+end
diff --git a/lib/berrypod/analytics/retention_worker.ex b/lib/berrypod/analytics/retention_worker.ex
new file mode 100644
index 0000000..4a36cf5
--- /dev/null
+++ b/lib/berrypod/analytics/retention_worker.ex
@@ -0,0 +1,30 @@
+defmodule Berrypod.Analytics.RetentionWorker do
+ @moduledoc """
+ Oban worker that deletes analytics events older than the retention period.
+
+ Runs daily via cron. Default retention is 12 months.
+ """
+
+ use Oban.Worker, queue: :default, max_attempts: 1
+
+ alias Berrypod.Analytics
+
+ @default_retention_months 12
+
+ @impl Oban.Worker
+ def perform(%Oban.Job{}) do
+ cutoff =
+ DateTime.utc_now()
+ |> DateTime.add(-@default_retention_months * 30, :day)
+ |> DateTime.truncate(:second)
+
+ {deleted, _} = Analytics.delete_events_before(cutoff)
+
+ if deleted > 0 do
+ require Logger
+ Logger.info("Analytics retention: deleted #{deleted} events older than #{cutoff}")
+ end
+
+ :ok
+ end
+end
diff --git a/lib/berrypod/analytics/salt.ex b/lib/berrypod/analytics/salt.ex
new file mode 100644
index 0000000..466bfe0
--- /dev/null
+++ b/lib/berrypod/analytics/salt.ex
@@ -0,0 +1,75 @@
+defmodule Berrypod.Analytics.Salt do
+ @moduledoc """
+ Daily-rotating salt for privacy-friendly visitor hashing.
+
+ Generates a random 32-byte salt on startup and rotates it at midnight UTC.
+ The visitor_hash is SHA256(salt + IP + UA) truncated to 8 bytes — enough
+ for unique visitor counting without being reversible. Because the salt
+ changes daily, the same visitor gets a different hash each day.
+ """
+
+ use GenServer
+
+ @salt_bytes 32
+ @hash_bytes 8
+
+ # ── Public API ──
+
+ def start_link(opts \\ []) do
+ GenServer.start_link(__MODULE__, opts, name: __MODULE__)
+ end
+
+ @doc """
+ Hashes a visitor's IP and user agent into an 8-byte binary.
+ """
+ def hash_visitor(ip, user_agent) when is_tuple(ip) do
+ hash_visitor(:inet.ntoa(ip) |> to_string(), user_agent)
+ end
+
+ def hash_visitor(ip, user_agent) when is_binary(ip) and is_binary(user_agent) do
+ salt = GenServer.call(__MODULE__, :get_salt)
+
+ :crypto.hash(:sha256, [salt, ip, user_agent])
+ |> binary_part(0, @hash_bytes)
+ end
+
+ # ── GenServer callbacks ──
+
+ @impl true
+ def init(_opts) do
+ schedule_rotation()
+ {:ok, %{salt: generate_salt()}}
+ end
+
+ @impl true
+ def handle_call(:get_salt, _from, state) do
+ {:reply, state.salt, state}
+ end
+
+ @impl true
+ def handle_info(:rotate, _state) do
+ schedule_rotation()
+ {:noreply, %{salt: generate_salt()}}
+ end
+
+ # ── Private ──
+
+ defp generate_salt, do: :crypto.strong_rand_bytes(@salt_bytes)
+
+ defp schedule_rotation do
+ ms_until_midnight = ms_until_next_midnight()
+ Process.send_after(self(), :rotate, ms_until_midnight)
+ end
+
+ defp ms_until_next_midnight do
+ now = DateTime.utc_now()
+
+ midnight =
+ now
+ |> DateTime.to_date()
+ |> Date.add(1)
+ |> DateTime.new!(~T[00:00:00], "Etc/UTC")
+
+ DateTime.diff(midnight, now, :millisecond)
+ end
+end
diff --git a/lib/berrypod/analytics/ua_parser.ex b/lib/berrypod/analytics/ua_parser.ex
new file mode 100644
index 0000000..dfbd1eb
--- /dev/null
+++ b/lib/berrypod/analytics/ua_parser.ex
@@ -0,0 +1,75 @@
+defmodule Berrypod.Analytics.UAParser do
+ @moduledoc """
+ Lightweight user agent parsing — extracts browser and OS names.
+
+ No external dependencies. Handles 95%+ of real-world traffic correctly.
+ Order matters: more specific checks (Edge before Chrome, iOS before macOS) come first.
+ """
+
+ @doc """
+ Returns `{browser, os}` tuple from a user agent string.
+
+ iex> Berrypod.Analytics.UAParser.parse("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
+ {"Chrome", "macOS"}
+
+ """
+ @spec parse(String.t()) :: {String.t(), String.t()}
+ def parse(ua) when is_binary(ua) do
+ {parse_browser(ua), parse_os(ua)}
+ end
+
+ def parse(_), do: {"Other", "Other"}
+
+ defp parse_browser(ua) do
+ cond do
+ String.contains?(ua, "bot") or String.contains?(ua, "Bot") or
+ String.contains?(ua, "crawl") or String.contains?(ua, "Crawl") or
+ String.contains?(ua, "spider") or String.contains?(ua, "Spider") ->
+ "Bot"
+
+ String.contains?(ua, "Firefox/") ->
+ "Firefox"
+
+ String.contains?(ua, "Edg/") ->
+ "Edge"
+
+ String.contains?(ua, "OPR/") or String.contains?(ua, "Opera") ->
+ "Opera"
+
+ String.contains?(ua, "Chrome/") ->
+ "Chrome"
+
+ String.contains?(ua, "Safari/") ->
+ "Safari"
+
+ true ->
+ "Other"
+ end
+ end
+
+ defp parse_os(ua) do
+ cond do
+ String.contains?(ua, "iPhone") or String.contains?(ua, "iPad") or
+ String.contains?(ua, "iPod") ->
+ "iOS"
+
+ String.contains?(ua, "Android") ->
+ "Android"
+
+ String.contains?(ua, "Mac OS X") or String.contains?(ua, "Macintosh") ->
+ "macOS"
+
+ String.contains?(ua, "Windows") ->
+ "Windows"
+
+ String.contains?(ua, "Linux") ->
+ "Linux"
+
+ String.contains?(ua, "CrOS") ->
+ "ChromeOS"
+
+ true ->
+ "Other"
+ end
+ end
+end
diff --git a/lib/berrypod/application.ex b/lib/berrypod/application.ex
index b2e2e8f..516bd84 100644
--- a/lib/berrypod/application.ex
+++ b/lib/berrypod/application.ex
@@ -22,6 +22,9 @@ defmodule Berrypod.Application do
{Phoenix.PubSub, name: Berrypod.PubSub},
# Background job processing
{Oban, Application.fetch_env!(:berrypod, Oban)},
+ # Analytics: daily-rotating salt and ETS event buffer
+ Berrypod.Analytics.Salt,
+ Berrypod.Analytics.Buffer,
# Image variant cache - ensures all variants exist on startup
Berrypod.Images.VariantCache,
# Start to serve requests
diff --git a/lib/berrypod_web/analytics_hook.ex b/lib/berrypod_web/analytics_hook.ex
new file mode 100644
index 0000000..9424c45
--- /dev/null
+++ b/lib/berrypod_web/analytics_hook.ex
@@ -0,0 +1,100 @@
+defmodule BerrypodWeb.AnalyticsHook do
+ @moduledoc """
+ LiveView on_mount hook for analytics — Layer 2 of the progressive pipeline.
+
+ Reads analytics data from the session (set by the Plugs.Analytics plug) and
+ tracks subsequent SPA navigations via handle_params. Skips the initial mount
+ since the plug already recorded that pageview.
+
+ Also handles the `analytics:screen` event from the JS hook (Layer 3) to
+ capture screen width for device classification.
+ """
+
+ import Phoenix.Component, only: [assign: 3]
+ import Phoenix.LiveView, only: [attach_hook: 4, connected?: 1]
+
+ alias Berrypod.Analytics
+
+ def on_mount(:track, _params, session, socket) do
+ socket =
+ socket
+ |> assign(:analytics_visitor_hash, session["analytics_visitor_hash"])
+ |> assign(:analytics_browser, session["analytics_browser"])
+ |> assign(:analytics_os, session["analytics_os"])
+ |> assign(:analytics_referrer, session["analytics_referrer"])
+ |> assign(:analytics_referrer_source, session["analytics_referrer_source"])
+ |> assign(:analytics_utm_source, session["analytics_utm_source"])
+ |> assign(:analytics_utm_medium, session["analytics_utm_medium"])
+ |> assign(:analytics_utm_campaign, session["analytics_utm_campaign"])
+ |> assign(:analytics_screen_size, session["analytics_screen_size"])
+ |> assign(:analytics_country_code, session["country_code"])
+ |> assign(:analytics_initial_mount, true)
+
+ socket =
+ if connected?(socket) and socket.assigns.analytics_visitor_hash do
+ socket
+ |> attach_hook(:analytics_params, :handle_params, &handle_analytics_params/3)
+ |> attach_hook(:analytics_events, :handle_event, &handle_analytics_event/3)
+ else
+ socket
+ end
+
+ {:cont, socket}
+ end
+
+ defp handle_analytics_params(_params, uri, socket) do
+ # Skip the initial mount — the plug already recorded this pageview
+ if socket.assigns.analytics_initial_mount do
+ {:cont, assign(socket, :analytics_initial_mount, false)}
+ else
+ # Skip if admin user is browsing
+ if admin?(socket) do
+ {:cont, socket}
+ else
+ pathname = URI.parse(uri).path
+
+ Analytics.track_pageview(%{
+ pathname: pathname,
+ visitor_hash: socket.assigns.analytics_visitor_hash,
+ referrer: socket.assigns.analytics_referrer,
+ referrer_source: socket.assigns.analytics_referrer_source,
+ utm_source: socket.assigns.analytics_utm_source,
+ utm_medium: socket.assigns.analytics_utm_medium,
+ utm_campaign: socket.assigns.analytics_utm_campaign,
+ country_code: socket.assigns.analytics_country_code,
+ screen_size: socket.assigns.analytics_screen_size,
+ browser: socket.assigns.analytics_browser,
+ os: socket.assigns.analytics_os
+ })
+
+ # Clear referrer/UTMs after first SPA navigation — they only apply to the entry
+ {:cont,
+ socket
+ |> assign(:analytics_referrer, nil)
+ |> assign(:analytics_referrer_source, nil)
+ |> assign(:analytics_utm_source, nil)
+ |> assign(:analytics_utm_medium, nil)
+ |> assign(:analytics_utm_campaign, nil)}
+ end
+ end
+ end
+
+ defp handle_analytics_event("analytics:screen", %{"width" => width}, socket)
+ when is_integer(width) do
+ screen_size = classify_screen(width)
+ {:cont, assign(socket, :analytics_screen_size, screen_size)}
+ end
+
+ defp handle_analytics_event(_event, _params, socket), do: {:cont, socket}
+
+ defp classify_screen(width) when width < 768, do: "mobile"
+ defp classify_screen(width) when width < 1024, do: "tablet"
+ defp classify_screen(_width), do: "desktop"
+
+ defp admin?(socket) do
+ case socket.assigns[:current_scope] do
+ %{user: %{}} -> true
+ _ -> false
+ end
+ end
+end
diff --git a/lib/berrypod_web/components/layouts/admin.html.heex b/lib/berrypod_web/components/layouts/admin.html.heex
index 0cc73d7..b33c257 100644
--- a/lib/berrypod_web/components/layouts/admin.html.heex
+++ b/lib/berrypod_web/components/layouts/admin.html.heex
@@ -62,6 +62,14 @@
<.icon name="hero-home" class="size-5" /> Dashboard
+
+ <.link
+ navigate={~p"/admin/analytics"}
+ class={admin_nav_active?(@current_path, "/admin/analytics")}
+ >
+ <.icon name="hero-chart-bar" class="size-5" /> Analytics
+
+
<.link
navigate={~p"/admin/orders"}
diff --git a/lib/berrypod_web/components/layouts/shop.html.heex b/lib/berrypod_web/components/layouts/shop.html.heex
index 86b3e32..680840e 100644
--- a/lib/berrypod_web/components/layouts/shop.html.heex
+++ b/lib/berrypod_web/components/layouts/shop.html.heex
@@ -1,2 +1,3 @@
<.shop_flash_group flash={@flash} />
+
{@inner_content}
diff --git a/lib/berrypod_web/controllers/checkout_controller.ex b/lib/berrypod_web/controllers/checkout_controller.ex
index a247b46..b6de94c 100644
--- a/lib/berrypod_web/controllers/checkout_controller.ex
+++ b/lib/berrypod_web/controllers/checkout_controller.ex
@@ -1,7 +1,7 @@
defmodule BerrypodWeb.CheckoutController do
use BerrypodWeb, :controller
- alias Berrypod.Cart
+ alias Berrypod.{Analytics, Cart}
alias Berrypod.Orders
alias Berrypod.Shipping
@@ -16,6 +16,7 @@ defmodule BerrypodWeb.CheckoutController do
|> put_flash(:error, "Your basket is empty")
|> redirect(to: ~p"/cart")
else
+ track_checkout_start(conn)
create_checkout(conn, hydrated)
end
end
@@ -126,4 +127,15 @@ defmodule BerrypodWeb.CheckoutController do
end
defp maybe_add_option(options, _result, _name, _min, _max), do: options
+
+ defp track_checkout_start(conn) do
+ visitor_hash = get_session(conn, "analytics_visitor_hash")
+
+ if visitor_hash do
+ Analytics.track_event("checkout_start", %{
+ pathname: "/checkout",
+ visitor_hash: visitor_hash
+ })
+ end
+ end
end
diff --git a/lib/berrypod_web/live/admin/analytics.ex b/lib/berrypod_web/live/admin/analytics.ex
new file mode 100644
index 0000000..0ca7104
--- /dev/null
+++ b/lib/berrypod_web/live/admin/analytics.ex
@@ -0,0 +1,459 @@
+defmodule BerrypodWeb.Admin.Analytics do
+ use BerrypodWeb, :live_view
+
+ alias Berrypod.Analytics
+ alias Berrypod.Cart
+
+ @periods %{
+ "today" => 0,
+ "7d" => 6,
+ "30d" => 29,
+ "12m" => 364
+ }
+
+ @impl true
+ def mount(_params, _session, socket) do
+ {:ok,
+ socket
+ |> assign(:page_title, "Analytics")
+ |> assign(:period, "30d")
+ |> assign(:tab, "pages")
+ |> load_analytics("30d")}
+ end
+
+ @impl true
+ def handle_event("change_period", %{"period" => period}, socket)
+ when is_map_key(@periods, period) do
+ {:noreply,
+ socket
+ |> assign(:period, period)
+ |> load_analytics(period)}
+ end
+
+ def handle_event("change_tab", %{"tab" => tab}, socket)
+ when tab in ["pages", "sources", "countries", "devices", "funnel"] do
+ {:noreply, assign(socket, :tab, tab)}
+ end
+
+ # ── Data loading ──
+
+ defp load_analytics(socket, period) do
+ range = date_range(period)
+
+ 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_by_date, Analytics.visitors_by_date(range))
+ |> assign(:top_pages, Analytics.top_pages(range))
+ |> assign(:top_sources, Analytics.top_sources(range))
+ |> assign(:top_referrers, Analytics.top_referrers(range))
+ |> assign(:top_countries, Analytics.top_countries(range))
+ |> assign(:browsers, Analytics.device_breakdown(range, :browser))
+ |> assign(:oses, Analytics.device_breakdown(range, :os))
+ |> assign(:screen_sizes, Analytics.device_breakdown(range, :screen_size))
+ |> assign(:funnel, Analytics.funnel(range))
+ |> assign(:revenue, Analytics.total_revenue(range))
+ end
+
+ defp date_range(period) do
+ days = Map.fetch!(@periods, period)
+ today = Date.utc_today()
+ start_date = Date.add(today, -days)
+ end_date = Date.add(today, 1)
+
+ {DateTime.new!(start_date, ~T[00:00:00], "Etc/UTC"),
+ DateTime.new!(end_date, ~T[00:00:00], "Etc/UTC")}
+ end
+
+ # ── Render ──
+
+ @impl true
+ def render(assigns) do
+ ~H"""
+ <.header>Analytics
+
+ <%!-- Period selector --%>
+
+
+
+
+ <%!-- 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" />
+
+
+ <%!-- Visitor trend chart --%>
+
+
+ Visitors over time
+
+ <.bar_chart data={@visitors_by_date} />
+
+
+ <%!-- Detail tabs --%>
+
+
+
+
+ <%!-- Tab content --%>
+
+ <.tab_content tab={@tab} {assigns} />
+
+ """
+ end
+
+ # ── Stat card component ──
+
+ attr :label, :string, required: true
+ attr :value, :any, required: true
+ attr :icon, :string, required: true
+
+ defp stat_card(assigns) do
+ ~H"""
+
+
+
+ <.icon name={@icon} class="size-5" />
+
+
+
{@value}
+
+ {@label}
+
+
+
+
+ """
+ end
+
+ # ── Bar chart (server-rendered SVG) ──
+
+ attr :data, :list, required: true
+
+ defp bar_chart(assigns) do
+ data = assigns.data
+ max_val = data |> Enum.map(& &1.visitors) |> Enum.max(fn -> 1 end)
+ chart_height = 120
+ bar_count = max(length(data), 1)
+
+ bars =
+ data
+ |> Enum.with_index()
+ |> Enum.map(fn {%{date: date, visitors: visitors}, i} ->
+ bar_height = if max_val > 0, do: visitors / max_val * chart_height, else: 0
+ bar_width = max(800 / bar_count - 2, 1)
+ x = i * (800 / bar_count) + 1
+
+ %{
+ x: x,
+ y: chart_height - bar_height,
+ width: bar_width,
+ height: max(bar_height, 1),
+ date: date,
+ visitors: visitors
+ }
+ end)
+
+ assigns = assign(assigns, bars: bars, chart_height: chart_height)
+
+ ~H"""
+
+ No data for this period
+
+
+ """
+ end
+
+ # ── Tab content ──
+
+ defp tab_content(%{tab: "pages"} = assigns) do
+ ~H"""
+ Top pages
+ <.detail_table
+ rows={@top_pages}
+ empty_message="No page data yet"
+ columns={[
+ %{label: "Page", key: :pathname},
+ %{label: "Visitors", key: :visitors, align: :right},
+ %{label: "Pageviews", key: :pageviews, align: :right}
+ ]}
+ />
+ """
+ end
+
+ defp tab_content(%{tab: "sources"} = assigns) do
+ ~H"""
+ Top sources
+ <.detail_table
+ rows={@top_sources}
+ empty_message="No referrer data yet"
+ columns={[
+ %{label: "Source", key: :source},
+ %{label: "Visitors", key: :visitors, align: :right}
+ ]}
+ />
+ Top referrers
+ <.detail_table
+ rows={@top_referrers}
+ empty_message="No referrer data yet"
+ columns={[
+ %{label: "Referrer", key: :referrer},
+ %{label: "Visitors", key: :visitors, align: :right}
+ ]}
+ />
+ """
+ end
+
+ defp tab_content(%{tab: "countries"} = assigns) do
+ ~H"""
+ Countries
+ <.detail_table
+ rows={Enum.map(@top_countries, fn c -> %{c | country_code: country_name(c.country_code)} end)}
+ empty_message="No country data yet"
+ columns={[
+ %{label: "Country", key: :country_code},
+ %{label: "Visitors", key: :visitors, align: :right}
+ ]}
+ />
+ """
+ end
+
+ defp tab_content(%{tab: "devices"} = assigns) do
+ ~H"""
+ Browsers
+ <.detail_table
+ rows={@browsers}
+ empty_message="No browser data yet"
+ columns={[
+ %{label: "Browser", key: :name},
+ %{label: "Visitors", key: :visitors, align: :right}
+ ]}
+ />
+
+ Operating systems
+
+ <.detail_table
+ rows={@oses}
+ empty_message="No OS data yet"
+ columns={[
+ %{label: "OS", key: :name},
+ %{label: "Visitors", key: :visitors, align: :right}
+ ]}
+ />
+ Screen sizes
+ <.detail_table
+ rows={@screen_sizes}
+ empty_message="No screen data yet"
+ columns={[
+ %{label: "Size", key: :name},
+ %{label: "Visitors", key: :visitors, align: :right}
+ ]}
+ />
+ """
+ end
+
+ defp tab_content(%{tab: "funnel"} = assigns) do
+ ~H"""
+
+ Conversion funnel
+
+ <.funnel_chart funnel={@funnel} revenue={@revenue} />
+ """
+ end
+
+ # ── Detail table ──
+
+ attr :rows, :list, required: true
+ attr :columns, :list, required: true
+ attr :empty_message, :string, default: "No data"
+
+ defp detail_table(assigns) do
+ ~H"""
+
+ {@empty_message}
+
+
+
+
+ |
+ {col.label}
+ |
+
+
+
+
+ |
+ {Map.get(row, col.key)}
+ |
+
+
+
+ """
+ end
+
+ # ── Funnel chart ──
+
+ attr :funnel, :map, required: true
+ attr :revenue, :integer, required: true
+
+ defp funnel_chart(assigns) do
+ steps = [
+ {"Product views", assigns.funnel.product_views},
+ {"Add to cart", assigns.funnel.add_to_carts},
+ {"Checkout", assigns.funnel.checkouts},
+ {"Purchase", assigns.funnel.purchases}
+ ]
+
+ max_val = steps |> Enum.map(&elem(&1, 1)) |> Enum.max(fn -> 1 end)
+
+ steps_with_rates =
+ steps
+ |> Enum.with_index()
+ |> Enum.map(fn {{label, count}, i} ->
+ prev_count = if i > 0, do: elem(Enum.at(steps, i - 1), 1), else: count
+ rate = if prev_count > 0, do: round(count / prev_count * 100), else: 0
+ width_pct = if max_val > 0, do: max(count / max_val * 100, 5), else: 5
+
+ %{label: label, count: count, rate: rate, width_pct: width_pct, index: i}
+ end)
+
+ assigns = assign(assigns, steps: steps_with_rates)
+
+ ~H"""
+
+ No funnel data yet
+
+ 0} style="display: flex; flex-direction: column; gap: 0.5rem;">
+
+
+ {step.label}
+
+
+
+ {step.count}
+
+
+
0}
+ style="font-size: 0.75rem; color: color-mix(in oklch, var(--color-base-content) 60%, transparent);"
+ >
+ {step.rate}%
+
+
+
0} style="margin-top: 0.5rem; font-size: 0.875rem; font-weight: 600;">
+ Revenue: {Cart.format_price(@revenue)}
+
+
+ """
+ end
+
+ # ── Helpers ──
+
+ defp period_label("today"), do: "Today"
+ defp period_label("7d"), do: "7 days"
+ defp period_label("30d"), do: "30 days"
+ defp period_label("12m"), do: "12 months"
+
+ defp format_number(n) when n >= 1_000_000, do: "#{Float.round(n / 1_000_000, 1)}M"
+ defp format_number(n) when n >= 1_000, do: "#{Float.round(n / 1_000, 1)}k"
+ defp format_number(n), do: to_string(n)
+
+ defp format_duration(seconds) when seconds < 60, do: "#{seconds}s"
+
+ defp format_duration(seconds) do
+ mins = div(seconds, 60)
+ secs = rem(seconds, 60)
+ "#{mins}m #{secs}s"
+ end
+
+ @country_names %{
+ "GB" => "United Kingdom",
+ "US" => "United States",
+ "CA" => "Canada",
+ "AU" => "Australia",
+ "DE" => "Germany",
+ "FR" => "France",
+ "NL" => "Netherlands",
+ "IE" => "Ireland",
+ "AT" => "Austria",
+ "BE" => "Belgium",
+ "IT" => "Italy",
+ "ES" => "Spain",
+ "PT" => "Portugal",
+ "SE" => "Sweden",
+ "NO" => "Norway",
+ "DK" => "Denmark",
+ "FI" => "Finland",
+ "PL" => "Poland",
+ "CH" => "Switzerland",
+ "NZ" => "New Zealand",
+ "JP" => "Japan",
+ "IN" => "India",
+ "BR" => "Brazil",
+ "MX" => "Mexico"
+ }
+
+ defp country_name(code) do
+ Map.get(@country_names, code, code)
+ end
+end
diff --git a/lib/berrypod_web/live/shop/checkout_success.ex b/lib/berrypod_web/live/shop/checkout_success.ex
index 6455814..3a00015 100644
--- a/lib/berrypod_web/live/shop/checkout_success.ex
+++ b/lib/berrypod_web/live/shop/checkout_success.ex
@@ -1,7 +1,7 @@
defmodule BerrypodWeb.Shop.CheckoutSuccess do
use BerrypodWeb, :live_view
- alias Berrypod.Orders
+ alias Berrypod.{Analytics, Orders}
@impl true
def mount(%{"session_id" => session_id}, _session, socket) do
@@ -12,6 +12,15 @@ defmodule BerrypodWeb.Shop.CheckoutSuccess do
Phoenix.PubSub.subscribe(Berrypod.PubSub, "order:#{order.id}:status")
end
+ # Track purchase event
+ if order && connected?(socket) && socket.assigns[:analytics_visitor_hash] do
+ Analytics.track_event("purchase", %{
+ pathname: "/checkout/success",
+ visitor_hash: socket.assigns.analytics_visitor_hash,
+ revenue: order.total
+ })
+ end
+
# Clear the cart after successful checkout
socket =
if order && connected?(socket) do
diff --git a/lib/berrypod_web/live/shop/product_show.ex b/lib/berrypod_web/live/shop/product_show.ex
index cd50729..fb02eb9 100644
--- a/lib/berrypod_web/live/shop/product_show.ex
+++ b/lib/berrypod_web/live/shop/product_show.ex
@@ -1,7 +1,7 @@
defmodule BerrypodWeb.Shop.ProductShow do
use BerrypodWeb, :live_view
- alias Berrypod.Cart
+ alias Berrypod.{Analytics, Cart}
alias Berrypod.Images.Optimizer
alias Berrypod.Products
alias Berrypod.Products.{Product, ProductImage}
@@ -41,6 +41,13 @@ defmodule BerrypodWeb.Shop.ProductShow do
display_price = variant_price(selected_variant, product)
gallery_images = filter_gallery_images(all_images, selected_options["Color"])
+ if connected?(socket) and socket.assigns[:analytics_visitor_hash] do
+ Analytics.track_event("product_view", %{
+ pathname: "/products/#{slug}",
+ visitor_hash: socket.assigns.analytics_visitor_hash
+ })
+ end
+
socket =
socket
|> assign(:page_title, product.title)
@@ -177,6 +184,13 @@ defmodule BerrypodWeb.Shop.ProductShow do
if variant do
cart = Cart.add_item(socket.assigns.raw_cart, variant.id, socket.assigns.quantity)
+ if socket.assigns[:analytics_visitor_hash] do
+ Analytics.track_event("add_to_cart", %{
+ pathname: "/products/#{socket.assigns.product.slug}",
+ visitor_hash: socket.assigns.analytics_visitor_hash
+ })
+ end
+
socket =
socket
|> BerrypodWeb.CartHook.broadcast_and_update(cart)
diff --git a/lib/berrypod_web/plugs/analytics.ex b/lib/berrypod_web/plugs/analytics.ex
new file mode 100644
index 0000000..127bfbf
--- /dev/null
+++ b/lib/berrypod_web/plugs/analytics.ex
@@ -0,0 +1,112 @@
+defmodule BerrypodWeb.Plugs.Analytics do
+ @moduledoc """
+ Plug that records a pageview event on every shop HTTP request.
+
+ This is Layer 1 of the progressive analytics pipeline — it fires on every
+ GET request regardless of whether JS is enabled. Computes a privacy-friendly
+ visitor hash, parses the user agent, extracts referrer and UTM params, and
+ buffers the event for batch writing to SQLite.
+
+ Also stores analytics data in the session so the LiveView hook (Layer 2)
+ can read it for subsequent SPA navigations.
+ """
+
+ import Plug.Conn
+
+ alias Berrypod.Analytics
+ alias Berrypod.Analytics.{Salt, UAParser, Referrer}
+
+ def init(opts), do: opts
+
+ def call(%{method: "GET"} = conn, _opts) do
+ ua = get_req_header(conn, "user-agent") |> List.first("")
+ {browser, os} = UAParser.parse(ua)
+
+ # Skip bots
+ if browser == "Bot" do
+ conn
+ else
+ # Skip if the logged-in admin is browsing
+ if admin?(conn) do
+ prepare_session(conn, ua, browser, os)
+ else
+ record_and_prepare(conn, ua, browser, os)
+ end
+ end
+ end
+
+ def call(conn, _opts), do: conn
+
+ defp record_and_prepare(conn, ua, browser, os) do
+ visitor_hash = Salt.hash_visitor(conn.remote_ip, ua)
+ {referrer, referrer_source} = extract_referrer(conn)
+ utm = extract_utms(conn)
+ country_code = get_session(conn, "country_code")
+ screen_size = get_session(conn, "analytics_screen_size")
+
+ Analytics.track_pageview(%{
+ pathname: conn.request_path,
+ visitor_hash: visitor_hash,
+ referrer: referrer,
+ referrer_source: referrer_source,
+ utm_source: utm.source,
+ utm_medium: utm.medium,
+ utm_campaign: utm.campaign,
+ country_code: country_code,
+ screen_size: screen_size,
+ browser: browser,
+ os: os
+ })
+
+ conn
+ |> put_session("analytics_visitor_hash", visitor_hash)
+ |> put_session("analytics_browser", browser)
+ |> put_session("analytics_os", os)
+ |> put_session("analytics_referrer", referrer)
+ |> put_session("analytics_referrer_source", referrer_source)
+ |> put_session("analytics_utm_source", utm.source)
+ |> put_session("analytics_utm_medium", utm.medium)
+ |> put_session("analytics_utm_campaign", utm.campaign)
+ end
+
+ # Store session data for the hook even when we skip recording for admins
+ defp prepare_session(conn, ua, browser, os) do
+ visitor_hash = Salt.hash_visitor(conn.remote_ip, ua)
+
+ conn
+ |> put_session("analytics_visitor_hash", visitor_hash)
+ |> put_session("analytics_browser", browser)
+ |> put_session("analytics_os", os)
+ end
+
+ defp admin?(conn) do
+ case conn.assigns[:current_scope] do
+ %{user: %{}} -> true
+ _ -> false
+ end
+ end
+
+ defp extract_referrer(conn) do
+ referrer_url = get_req_header(conn, "referer") |> List.first()
+ {referrer, source} = Referrer.parse(referrer_url)
+
+ # Don't count self-referrals
+ host = conn.host
+
+ if referrer && referrer == host do
+ {nil, nil}
+ else
+ {referrer, source}
+ end
+ end
+
+ defp extract_utms(conn) do
+ params = conn.query_params || %{}
+
+ %{
+ source: Map.get(params, "utm_source"),
+ medium: Map.get(params, "utm_medium"),
+ campaign: Map.get(params, "utm_campaign")
+ }
+ end
+end
diff --git a/lib/berrypod_web/router.ex b/lib/berrypod_web/router.ex
index fa1926b..4cff64a 100644
--- a/lib/berrypod_web/router.ex
+++ b/lib/berrypod_web/router.ex
@@ -37,6 +37,7 @@ defmodule BerrypodWeb.Router do
pipeline :shop do
plug :put_root_layout, html: {BerrypodWeb.Layouts, :shop_root}
plug BerrypodWeb.Plugs.LoadTheme
+ plug BerrypodWeb.Plugs.Analytics
end
pipeline :admin do
@@ -63,7 +64,8 @@ defmodule BerrypodWeb.Router do
{BerrypodWeb.ThemeHook, :mount_theme},
{BerrypodWeb.ThemeHook, :require_site_live},
{BerrypodWeb.CartHook, :mount_cart},
- {BerrypodWeb.SearchHook, :mount_search}
+ {BerrypodWeb.SearchHook, :mount_search},
+ {BerrypodWeb.AnalyticsHook, :track}
] do
live "/", Shop.Home, :index
live "/about", Shop.Content, :about
@@ -170,6 +172,7 @@ defmodule BerrypodWeb.Router do
{BerrypodWeb.AdminLayoutHook, :assign_current_path}
] do
live "/", Admin.Dashboard, :index
+ live "/analytics", Admin.Analytics, :index
live "/orders", Admin.Orders, :index
live "/orders/:id", Admin.OrderShow, :show
live "/products", Admin.Products, :index
diff --git a/priv/repo/migrations/20260222112942_create_analytics_events.exs b/priv/repo/migrations/20260222112942_create_analytics_events.exs
new file mode 100644
index 0000000..abaa4c1
--- /dev/null
+++ b/priv/repo/migrations/20260222112942_create_analytics_events.exs
@@ -0,0 +1,30 @@
+defmodule Berrypod.Repo.Migrations.CreateAnalyticsEvents do
+ use Ecto.Migration
+
+ def change do
+ create table(:analytics_events, primary_key: false) do
+ add :id, :binary_id, primary_key: true
+ add :name, :string, null: false
+ add :pathname, :string, null: false
+ add :visitor_hash, :binary, null: false
+ add :session_hash, :binary, null: false
+ add :referrer, :string
+ add :referrer_source, :string
+ add :utm_source, :string
+ add :utm_medium, :string
+ add :utm_campaign, :string
+ add :country_code, :string, size: 2
+ add :screen_size, :string
+ add :browser, :string
+ add :os, :string
+ add :revenue, :integer
+
+ timestamps(type: :utc_datetime, updated_at: false)
+ end
+
+ create index(:analytics_events, [:inserted_at])
+ create index(:analytics_events, [:name, :inserted_at])
+ create index(:analytics_events, [:session_hash, :inserted_at])
+ create index(:analytics_events, [:pathname, :inserted_at])
+ end
+end
diff --git a/test/berrypod/analytics/buffer_test.exs b/test/berrypod/analytics/buffer_test.exs
new file mode 100644
index 0000000..eb9acc3
--- /dev/null
+++ b/test/berrypod/analytics/buffer_test.exs
@@ -0,0 +1,87 @@
+defmodule Berrypod.Analytics.BufferTest do
+ use Berrypod.DataCase, async: false
+
+ alias Berrypod.Analytics.Buffer
+ alias Berrypod.Analytics.Event
+ alias Berrypod.Repo
+
+ import Ecto.Query
+
+ setup do
+ # Flush any pending events from previous tests
+ send(Buffer, :flush)
+ :timer.sleep(50)
+ :ok
+ end
+
+ describe "record/1 and flush" do
+ test "buffered events are flushed to the database" do
+ visitor_hash = :crypto.strong_rand_bytes(8)
+
+ Buffer.record(%{
+ name: "pageview",
+ pathname: "/buffer-test",
+ visitor_hash: visitor_hash,
+ browser: "Chrome",
+ os: "macOS"
+ })
+
+ send(Buffer, :flush)
+ :timer.sleep(50)
+
+ events =
+ from(e in Event, where: e.visitor_hash == ^visitor_hash)
+ |> Repo.all()
+
+ assert length(events) == 1
+
+ event = hd(events)
+ assert event.name == "pageview"
+ assert event.pathname == "/buffer-test"
+ assert event.visitor_hash == visitor_hash
+ assert event.browser == "Chrome"
+ assert event.os == "macOS"
+ assert event.session_hash != nil
+ assert byte_size(event.session_hash) == 8
+ end
+
+ test "events within 30 min get the same session_hash" do
+ visitor_hash = :crypto.strong_rand_bytes(8)
+
+ Buffer.record(%{name: "pageview", pathname: "/", visitor_hash: visitor_hash})
+ Buffer.record(%{name: "pageview", pathname: "/products", visitor_hash: visitor_hash})
+
+ send(Buffer, :flush)
+ :timer.sleep(50)
+
+ events =
+ from(e in Event, where: e.visitor_hash == ^visitor_hash)
+ |> Repo.all()
+
+ assert length(events) == 2
+
+ session_hashes = Enum.map(events, & &1.session_hash) |> Enum.uniq()
+ assert length(session_hashes) == 1
+ end
+
+ test "different visitors get different session_hashes" do
+ visitor1 = :crypto.strong_rand_bytes(8)
+ visitor2 = :crypto.strong_rand_bytes(8)
+
+ Buffer.record(%{name: "pageview", pathname: "/", visitor_hash: visitor1})
+ Buffer.record(%{name: "pageview", pathname: "/", visitor_hash: visitor2})
+
+ send(Buffer, :flush)
+ :timer.sleep(50)
+
+ events =
+ from(e in Event, where: e.visitor_hash in [^visitor1, ^visitor2])
+ |> Repo.all()
+
+ assert length(events) == 2
+
+ session_hashes = Enum.map(events, & &1.session_hash) |> Enum.uniq()
+ assert length(session_hashes) == 2
+ end
+ end
+end
diff --git a/test/berrypod/analytics/referrer_test.exs b/test/berrypod/analytics/referrer_test.exs
new file mode 100644
index 0000000..14c7094
--- /dev/null
+++ b/test/berrypod/analytics/referrer_test.exs
@@ -0,0 +1,74 @@
+defmodule Berrypod.Analytics.ReferrerTest do
+ use ExUnit.Case, async: true
+
+ alias Berrypod.Analytics.Referrer
+
+ describe "parse/1" do
+ test "extracts Google search" do
+ assert Referrer.parse("https://www.google.com/search?q=test") == {"google.com", "Google"}
+ end
+
+ test "extracts Google with country TLD" do
+ assert Referrer.parse("https://www.google.co.uk/") == {"google.co.uk", "Google"}
+ end
+
+ test "extracts Facebook" do
+ assert Referrer.parse("https://www.facebook.com/some-page") == {"facebook.com", "Facebook"}
+ end
+
+ test "extracts Twitter / X" do
+ assert Referrer.parse("https://t.co/abc123") == {"t.co", "Telegram"}
+ assert Referrer.parse("https://twitter.com/user") == {"twitter.com", "Twitter"}
+ assert Referrer.parse("https://x.com/user") == {"x.com", "Twitter"}
+ end
+
+ test "extracts Reddit" do
+ assert Referrer.parse("https://www.reddit.com/r/elixir") == {"reddit.com", "Reddit"}
+ end
+
+ test "returns nil source for unknown domain" do
+ assert Referrer.parse("https://myblog.example.com/post/1") ==
+ {"myblog.example.com", nil}
+ end
+
+ test "strips www prefix" do
+ assert Referrer.parse("https://www.example.com/page") == {"example.com", nil}
+ end
+
+ test "returns nil for nil input" do
+ assert Referrer.parse(nil) == {nil, nil}
+ end
+
+ test "returns nil for empty string" do
+ assert Referrer.parse("") == {nil, nil}
+ end
+
+ test "returns nil for invalid URL" do
+ assert Referrer.parse("not a url") == {nil, nil}
+ end
+
+ test "extracts DuckDuckGo" do
+ assert Referrer.parse("https://duckduckgo.com/?q=test") == {"duckduckgo.com", "DuckDuckGo"}
+ end
+
+ test "extracts YouTube" do
+ assert Referrer.parse("https://www.youtube.com/watch?v=abc") == {"youtube.com", "YouTube"}
+ end
+
+ test "extracts Instagram" do
+ assert Referrer.parse("https://www.instagram.com/user") == {"instagram.com", "Instagram"}
+ end
+
+ test "extracts LinkedIn" do
+ assert Referrer.parse("https://www.linkedin.com/feed") == {"linkedin.com", "LinkedIn"}
+ end
+
+ test "extracts Pinterest" do
+ assert Referrer.parse("https://www.pinterest.com/pin/123") == {"pinterest.com", "Pinterest"}
+ end
+
+ test "extracts Bing" do
+ assert Referrer.parse("https://www.bing.com/search?q=test") == {"bing.com", "Bing"}
+ end
+ end
+end
diff --git a/test/berrypod/analytics/retention_worker_test.exs b/test/berrypod/analytics/retention_worker_test.exs
new file mode 100644
index 0000000..020bd8b
--- /dev/null
+++ b/test/berrypod/analytics/retention_worker_test.exs
@@ -0,0 +1,40 @@
+defmodule Berrypod.Analytics.RetentionWorkerTest do
+ use Berrypod.DataCase, async: false
+
+ alias Berrypod.Analytics.{Event, RetentionWorker}
+ alias Berrypod.Repo
+
+ test "deletes events older than 12 months" do
+ old = DateTime.add(DateTime.utc_now(), -400, :day) |> DateTime.truncate(:second)
+ recent = DateTime.utc_now() |> DateTime.truncate(:second)
+
+ Repo.insert_all(Event, [
+ [
+ id: Ecto.UUID.generate(),
+ name: "pageview",
+ pathname: "/old",
+ visitor_hash: :crypto.strong_rand_bytes(8),
+ session_hash: :crypto.strong_rand_bytes(8),
+ inserted_at: old
+ ],
+ [
+ id: Ecto.UUID.generate(),
+ name: "pageview",
+ pathname: "/recent",
+ visitor_hash: :crypto.strong_rand_bytes(8),
+ session_hash: :crypto.strong_rand_bytes(8),
+ inserted_at: recent
+ ]
+ ])
+
+ assert :ok = RetentionWorker.perform(%Oban.Job{})
+
+ events = Repo.all(Event)
+ assert length(events) == 1
+ assert hd(events).pathname == "/recent"
+ end
+
+ test "no-op when no old events exist" do
+ assert :ok = RetentionWorker.perform(%Oban.Job{})
+ end
+end
diff --git a/test/berrypod/analytics/salt_test.exs b/test/berrypod/analytics/salt_test.exs
new file mode 100644
index 0000000..ba71016
--- /dev/null
+++ b/test/berrypod/analytics/salt_test.exs
@@ -0,0 +1,37 @@
+defmodule Berrypod.Analytics.SaltTest do
+ use ExUnit.Case, async: true
+
+ alias Berrypod.Analytics.Salt
+
+ describe "hash_visitor/2" do
+ test "returns an 8-byte binary" do
+ hash = Salt.hash_visitor({127, 0, 0, 1}, "Mozilla/5.0")
+ assert is_binary(hash)
+ assert byte_size(hash) == 8
+ end
+
+ test "same inputs produce the same hash" do
+ hash1 = Salt.hash_visitor({127, 0, 0, 1}, "Mozilla/5.0")
+ hash2 = Salt.hash_visitor({127, 0, 0, 1}, "Mozilla/5.0")
+ assert hash1 == hash2
+ end
+
+ test "different IPs produce different hashes" do
+ hash1 = Salt.hash_visitor({127, 0, 0, 1}, "Mozilla/5.0")
+ hash2 = Salt.hash_visitor({192, 168, 1, 1}, "Mozilla/5.0")
+ assert hash1 != hash2
+ end
+
+ test "different user agents produce different hashes" do
+ hash1 = Salt.hash_visitor({127, 0, 0, 1}, "Mozilla/5.0 Chrome")
+ hash2 = Salt.hash_visitor({127, 0, 0, 1}, "Mozilla/5.0 Firefox")
+ assert hash1 != hash2
+ end
+
+ test "accepts IP as a binary string" do
+ hash1 = Salt.hash_visitor("127.0.0.1", "Mozilla/5.0")
+ hash2 = Salt.hash_visitor({127, 0, 0, 1}, "Mozilla/5.0")
+ assert hash1 == hash2
+ end
+ end
+end
diff --git a/test/berrypod/analytics/ua_parser_test.exs b/test/berrypod/analytics/ua_parser_test.exs
new file mode 100644
index 0000000..596cdf1
--- /dev/null
+++ b/test/berrypod/analytics/ua_parser_test.exs
@@ -0,0 +1,81 @@
+defmodule Berrypod.Analytics.UAParserTest do
+ use ExUnit.Case, async: true
+
+ alias Berrypod.Analytics.UAParser
+
+ describe "parse/1" do
+ test "detects Chrome on macOS" do
+ ua =
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
+
+ assert UAParser.parse(ua) == {"Chrome", "macOS"}
+ end
+
+ test "detects Firefox on Windows" do
+ ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0"
+ assert UAParser.parse(ua) == {"Firefox", "Windows"}
+ end
+
+ test "detects Safari on iOS (iPhone)" do
+ ua =
+ "Mozilla/5.0 (iPhone; CPU iPhone OS 17_2_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1"
+
+ assert UAParser.parse(ua) == {"Safari", "iOS"}
+ end
+
+ test "detects Chrome on Android" do
+ ua =
+ "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36"
+
+ assert UAParser.parse(ua) == {"Chrome", "Android"}
+ end
+
+ test "detects Edge on Windows" do
+ ua =
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0"
+
+ assert UAParser.parse(ua) == {"Edge", "Windows"}
+ end
+
+ test "detects Chrome on Linux" do
+ ua =
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
+
+ assert UAParser.parse(ua) == {"Chrome", "Linux"}
+ end
+
+ test "detects bots" do
+ ua = "Googlebot/2.1 (+http://www.google.com/bot.html)"
+ assert UAParser.parse(ua) == {"Bot", "Other"}
+ end
+
+ test "detects Bingbot" do
+ ua =
+ "Mozilla/5.0 (compatible; bingbot/2.0; +http://www.bing.com/bingbot.htm)"
+
+ assert UAParser.parse(ua) == {"Bot", "Other"}
+ end
+
+ test "returns Other/Other for nil" do
+ assert UAParser.parse(nil) == {"Other", "Other"}
+ end
+
+ test "returns Other/Other for empty string" do
+ assert UAParser.parse("") == {"Other", "Other"}
+ end
+
+ test "detects Opera" do
+ ua =
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 OPR/106.0.0.0"
+
+ assert UAParser.parse(ua) == {"Opera", "Windows"}
+ end
+
+ test "detects Safari on iPad (iOS)" do
+ ua =
+ "Mozilla/5.0 (iPad; CPU OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1"
+
+ assert UAParser.parse(ua) == {"Safari", "iOS"}
+ end
+ end
+end
diff --git a/test/berrypod/analytics_test.exs b/test/berrypod/analytics_test.exs
new file mode 100644
index 0000000..62da370
--- /dev/null
+++ b/test/berrypod/analytics_test.exs
@@ -0,0 +1,250 @@
+defmodule Berrypod.AnalyticsTest do
+ use Berrypod.DataCase, async: false
+
+ import Ecto.Query
+
+ alias Berrypod.Analytics
+ alias Berrypod.Analytics.{Buffer, Event}
+ alias Berrypod.Repo
+
+ setup do
+ # Flush any pending events then clear the table so each test starts clean
+ send(Buffer, :flush)
+ :timer.sleep(50)
+ Repo.delete_all(Event)
+ :ok
+ end
+
+ # Helper to insert events directly (bypassing the buffer for query tests)
+ defp insert_event(attrs) do
+ now = DateTime.utc_now() |> DateTime.truncate(:second)
+
+ defaults = %{
+ id: Ecto.UUID.generate(),
+ name: "pageview",
+ pathname: "/",
+ visitor_hash: :crypto.strong_rand_bytes(8),
+ session_hash: :crypto.strong_rand_bytes(8),
+ inserted_at: now
+ }
+
+ event = Map.merge(defaults, attrs)
+ Repo.insert_all(Event, [Map.to_list(event)])
+ event
+ end
+
+ defp today_range do
+ today = Date.utc_today()
+ start_dt = DateTime.new!(today, ~T[00:00:00], "Etc/UTC")
+ end_dt = DateTime.new!(Date.add(today, 1), ~T[00:00:00], "Etc/UTC")
+ {start_dt, end_dt}
+ end
+
+ describe "track_pageview/1" do
+ test "records a pageview through the buffer" do
+ visitor_hash = :crypto.strong_rand_bytes(8)
+
+ Analytics.track_pageview(%{pathname: "/test", visitor_hash: visitor_hash})
+
+ send(Buffer, :flush)
+ :timer.sleep(50)
+
+ [event] =
+ from(e in Event, where: e.visitor_hash == ^visitor_hash) |> Repo.all()
+
+ assert event.name == "pageview"
+ assert event.pathname == "/test"
+ end
+ end
+
+ describe "track_event/2" do
+ test "records a named event through the buffer" do
+ visitor_hash = :crypto.strong_rand_bytes(8)
+
+ Analytics.track_event("add_to_cart", %{
+ pathname: "/products/tee",
+ visitor_hash: visitor_hash
+ })
+
+ send(Buffer, :flush)
+ :timer.sleep(50)
+
+ [event] =
+ from(e in Event, where: e.visitor_hash == ^visitor_hash) |> Repo.all()
+
+ assert event.name == "add_to_cart"
+ assert event.pathname == "/products/tee"
+ end
+ end
+
+ describe "count_visitors/1" do
+ test "counts distinct visitors" do
+ v1 = :crypto.strong_rand_bytes(8)
+ v2 = :crypto.strong_rand_bytes(8)
+
+ insert_event(%{visitor_hash: v1, pathname: "/"})
+ insert_event(%{visitor_hash: v1, pathname: "/about"})
+ insert_event(%{visitor_hash: v2, pathname: "/"})
+
+ assert Analytics.count_visitors(today_range()) == 2
+ end
+
+ test "returns 0 for empty range" do
+ assert Analytics.count_visitors(today_range()) == 0
+ end
+ end
+
+ describe "count_pageviews/1" do
+ test "counts all pageview events" do
+ v1 = :crypto.strong_rand_bytes(8)
+
+ insert_event(%{visitor_hash: v1, pathname: "/"})
+ insert_event(%{visitor_hash: v1, pathname: "/about"})
+ insert_event(%{visitor_hash: v1, pathname: "/products/tee", name: "product_view"})
+
+ assert Analytics.count_pageviews(today_range()) == 2
+ end
+ end
+
+ describe "bounce_rate/1" do
+ test "100% bounce rate when all sessions have 1 pageview" do
+ s1 = :crypto.strong_rand_bytes(8)
+ s2 = :crypto.strong_rand_bytes(8)
+
+ insert_event(%{session_hash: s1, pathname: "/"})
+ insert_event(%{session_hash: s2, pathname: "/about"})
+
+ assert Analytics.bounce_rate(today_range()) == 100
+ end
+
+ test "0% bounce rate when all sessions have multiple pageviews" do
+ session = :crypto.strong_rand_bytes(8)
+
+ insert_event(%{session_hash: session, pathname: "/"})
+ insert_event(%{session_hash: session, pathname: "/about"})
+
+ assert Analytics.bounce_rate(today_range()) == 0
+ end
+
+ test "returns 0 for no data" do
+ assert Analytics.bounce_rate(today_range()) == 0
+ end
+ end
+
+ describe "top_pages/2" do
+ test "returns pages sorted by visitor count" do
+ v1 = :crypto.strong_rand_bytes(8)
+ v2 = :crypto.strong_rand_bytes(8)
+
+ insert_event(%{visitor_hash: v1, pathname: "/"})
+ insert_event(%{visitor_hash: v2, pathname: "/"})
+ insert_event(%{visitor_hash: v1, pathname: "/about"})
+
+ pages = Analytics.top_pages(today_range())
+ assert hd(pages).pathname == "/"
+ assert hd(pages).visitors == 2
+ end
+ end
+
+ describe "top_sources/2" do
+ test "returns sources sorted by visitor count" do
+ v1 = :crypto.strong_rand_bytes(8)
+ v2 = :crypto.strong_rand_bytes(8)
+
+ insert_event(%{visitor_hash: v1, referrer_source: "Google"})
+ insert_event(%{visitor_hash: v2, referrer_source: "Google"})
+ insert_event(%{visitor_hash: v1, referrer_source: "Facebook"})
+
+ sources = Analytics.top_sources(today_range())
+ assert hd(sources).source == "Google"
+ assert hd(sources).visitors == 2
+ end
+ end
+
+ describe "top_countries/2" do
+ test "returns countries sorted by visitor count" do
+ v1 = :crypto.strong_rand_bytes(8)
+ v2 = :crypto.strong_rand_bytes(8)
+
+ insert_event(%{visitor_hash: v1, country_code: "GB"})
+ insert_event(%{visitor_hash: v2, country_code: "GB"})
+ insert_event(%{visitor_hash: v1, country_code: "US"})
+
+ countries = Analytics.top_countries(today_range())
+ assert hd(countries).country_code == "GB"
+ assert hd(countries).visitors == 2
+ end
+ end
+
+ describe "device_breakdown/2" do
+ test "returns browser breakdown" do
+ v1 = :crypto.strong_rand_bytes(8)
+ v2 = :crypto.strong_rand_bytes(8)
+
+ insert_event(%{visitor_hash: v1, browser: "Chrome"})
+ insert_event(%{visitor_hash: v2, browser: "Chrome"})
+ insert_event(%{visitor_hash: v1, browser: "Firefox"})
+
+ browsers = Analytics.device_breakdown(today_range(), :browser)
+ assert hd(browsers).name == "Chrome"
+ assert hd(browsers).visitors == 2
+ end
+ end
+
+ describe "funnel/1" do
+ test "returns counts for each funnel step" do
+ v1 = :crypto.strong_rand_bytes(8)
+ v2 = :crypto.strong_rand_bytes(8)
+
+ insert_event(%{visitor_hash: v1, name: "product_view", pathname: "/products/tee"})
+ insert_event(%{visitor_hash: v2, name: "product_view", pathname: "/products/tee"})
+ insert_event(%{visitor_hash: v1, name: "add_to_cart", pathname: "/products/tee"})
+ insert_event(%{visitor_hash: v1, name: "checkout_start", pathname: "/checkout"})
+
+ insert_event(%{
+ visitor_hash: v1,
+ name: "purchase",
+ pathname: "/checkout/success",
+ revenue: 2500
+ })
+
+ funnel = Analytics.funnel(today_range())
+ assert funnel.product_views == 2
+ assert funnel.add_to_carts == 1
+ assert funnel.checkouts == 1
+ assert funnel.purchases == 1
+ end
+ end
+
+ describe "total_revenue/1" do
+ test "sums revenue from purchase events" do
+ v1 = :crypto.strong_rand_bytes(8)
+
+ insert_event(%{visitor_hash: v1, name: "purchase", pathname: "/", revenue: 2500})
+ insert_event(%{visitor_hash: v1, name: "purchase", pathname: "/", revenue: 1500})
+
+ assert Analytics.total_revenue(today_range()) == 4000
+ end
+
+ test "returns 0 when no purchases" do
+ assert Analytics.total_revenue(today_range()) == 0
+ end
+ end
+
+ describe "delete_events_before/1" do
+ test "deletes old events" do
+ old = DateTime.add(DateTime.utc_now(), -400, :day) |> DateTime.truncate(:second)
+ recent = DateTime.utc_now() |> DateTime.truncate(:second)
+
+ insert_event(%{inserted_at: old, pathname: "/old"})
+ insert_event(%{inserted_at: recent, pathname: "/recent"})
+
+ cutoff = DateTime.add(DateTime.utc_now(), -365, :day) |> DateTime.truncate(:second)
+ {deleted, _} = Analytics.delete_events_before(cutoff)
+
+ assert deleted == 1
+ assert [event] = Repo.all(Event)
+ assert event.pathname == "/recent"
+ end
+ end
+end
diff --git a/test/berrypod_web/live/admin/analytics_test.exs b/test/berrypod_web/live/admin/analytics_test.exs
new file mode 100644
index 0000000..964668d
--- /dev/null
+++ b/test/berrypod_web/live/admin/analytics_test.exs
@@ -0,0 +1,106 @@
+defmodule BerrypodWeb.Admin.AnalyticsTest do
+ use BerrypodWeb.ConnCase, async: false
+
+ import Phoenix.LiveViewTest
+ import Berrypod.AccountsFixtures
+
+ alias Berrypod.Analytics.{Buffer, Event}
+ alias Berrypod.Repo
+
+ setup do
+ send(Buffer, :flush)
+ :timer.sleep(50)
+
+ user = user_fixture()
+ %{user: user}
+ end
+
+ describe "unauthenticated" do
+ test "redirects to login", %{conn: conn} do
+ {:error, redirect} = live(conn, ~p"/admin/analytics")
+ assert {:redirect, %{to: path}} = redirect
+ assert path == ~p"/users/log-in"
+ end
+ end
+
+ describe "analytics dashboard" do
+ setup %{conn: conn, user: user} do
+ %{conn: log_in_user(conn, user)}
+ end
+
+ test "renders the analytics page", %{conn: conn} do
+ {:ok, _view, html} = live(conn, ~p"/admin/analytics")
+
+ assert html =~ "Analytics"
+ assert html =~ "Unique visitors"
+ assert html =~ "Total pageviews"
+ assert html =~ "Bounce rate"
+ assert html =~ "Visit duration"
+ end
+
+ test "shows zero state with no data", %{conn: conn} do
+ {:ok, _view, html} = live(conn, ~p"/admin/analytics")
+
+ assert html =~ "No data for this period"
+ end
+
+ test "shows data when events exist", %{conn: conn} do
+ now = DateTime.utc_now() |> DateTime.truncate(:second)
+
+ Repo.insert_all(Event, [
+ [
+ id: Ecto.UUID.generate(),
+ name: "pageview",
+ pathname: "/",
+ visitor_hash: :crypto.strong_rand_bytes(8),
+ session_hash: :crypto.strong_rand_bytes(8),
+ browser: "Chrome",
+ os: "macOS",
+ inserted_at: now
+ ]
+ ])
+
+ {:ok, view, _html} = live(conn, ~p"/admin/analytics")
+
+ assert has_element?(view, "rect")
+ end
+
+ test "changes period", %{conn: conn} do
+ {:ok, view, _html} = live(conn, ~p"/admin/analytics")
+
+ html = render_click(view, "change_period", %{"period" => "7d"})
+ assert html =~ "Analytics"
+ end
+
+ test "changes tab to sources", %{conn: conn} do
+ {:ok, view, _html} = live(conn, ~p"/admin/analytics")
+
+ html = render_click(view, "change_tab", %{"tab" => "sources"})
+ assert html =~ "Top sources"
+ assert html =~ "Top referrers"
+ end
+
+ test "changes tab to countries", %{conn: conn} do
+ {:ok, view, _html} = live(conn, ~p"/admin/analytics")
+
+ html = render_click(view, "change_tab", %{"tab" => "countries"})
+ assert html =~ "Countries"
+ end
+
+ test "changes tab to devices", %{conn: conn} do
+ {:ok, view, _html} = live(conn, ~p"/admin/analytics")
+
+ html = render_click(view, "change_tab", %{"tab" => "devices"})
+ assert html =~ "Browsers"
+ assert html =~ "Operating systems"
+ assert html =~ "Screen sizes"
+ end
+
+ test "changes tab to funnel", %{conn: conn} do
+ {:ok, view, _html} = live(conn, ~p"/admin/analytics")
+
+ html = render_click(view, "change_tab", %{"tab" => "funnel"})
+ assert html =~ "Conversion funnel"
+ end
+ end
+end
diff --git a/test/berrypod_web/plugs/analytics_test.exs b/test/berrypod_web/plugs/analytics_test.exs
new file mode 100644
index 0000000..1cf4c9b
--- /dev/null
+++ b/test/berrypod_web/plugs/analytics_test.exs
@@ -0,0 +1,115 @@
+defmodule BerrypodWeb.Plugs.AnalyticsTest do
+ use BerrypodWeb.ConnCase, async: false
+
+ import Ecto.Query
+
+ alias Berrypod.Analytics.{Buffer, Event}
+ alias Berrypod.Repo
+
+ setup do
+ send(Buffer, :flush)
+ :timer.sleep(50)
+ :ok
+ end
+
+ describe "analytics plug" do
+ test "records a pageview on GET request", %{conn: conn} do
+ conn
+ |> put_req_header("user-agent", "Mozilla/5.0 Chrome/120.0")
+ |> get(~p"/")
+
+ send(Buffer, :flush)
+ :timer.sleep(50)
+
+ events =
+ from(e in Event, where: e.pathname == "/" and e.name == "pageview")
+ |> Repo.all()
+
+ assert length(events) >= 1
+ event = hd(events)
+ assert event.browser == "Chrome"
+ end
+
+ test "does not record on POST request", %{conn: conn} do
+ count_before = Repo.aggregate(Event, :count)
+
+ conn
+ |> put_req_header("user-agent", "Mozilla/5.0 Chrome/120.0")
+ |> post(~p"/checkout", %{})
+
+ send(Buffer, :flush)
+ :timer.sleep(50)
+
+ count_after = Repo.aggregate(Event, :count)
+ # POST to /checkout shouldn't create a pageview event via the plug
+ # (it may fail with a redirect, but the plug should have skipped)
+ assert count_after == count_before
+ end
+
+ test "skips bots", %{conn: conn} do
+ count_before = Repo.aggregate(Event, :count)
+
+ conn
+ |> put_req_header("user-agent", "Googlebot/2.1 (+http://www.google.com/bot.html)")
+ |> get(~p"/")
+
+ send(Buffer, :flush)
+ :timer.sleep(50)
+
+ count_after = Repo.aggregate(Event, :count)
+ assert count_after == count_before
+ end
+
+ test "stores analytics data in session", %{conn: conn} do
+ conn =
+ conn
+ |> put_req_header("user-agent", "Mozilla/5.0 Firefox/121.0")
+ |> get(~p"/")
+
+ assert get_session(conn, "analytics_visitor_hash") |> is_binary()
+ assert get_session(conn, "analytics_browser") == "Firefox"
+ end
+
+ test "extracts referrer", %{conn: conn} do
+ conn
+ |> put_req_header("user-agent", "Mozilla/5.0 Chrome/120.0")
+ |> put_req_header("referer", "https://www.google.com/search?q=test")
+ |> get(~p"/")
+
+ send(Buffer, :flush)
+ :timer.sleep(50)
+
+ event =
+ from(e in Event,
+ where: e.referrer == "google.com",
+ order_by: [desc: e.inserted_at],
+ limit: 1
+ )
+ |> Repo.one()
+
+ assert event
+ assert event.referrer_source == "Google"
+ end
+
+ test "extracts UTM params", %{conn: conn} do
+ conn
+ |> put_req_header("user-agent", "Mozilla/5.0 Chrome/120.0")
+ |> get(~p"/?utm_source=newsletter&utm_medium=email&utm_campaign=spring")
+
+ send(Buffer, :flush)
+ :timer.sleep(50)
+
+ event =
+ from(e in Event,
+ where: e.utm_source == "newsletter",
+ order_by: [desc: e.inserted_at],
+ limit: 1
+ )
+ |> Repo.one()
+
+ assert event
+ assert event.utm_medium == "email"
+ assert event.utm_campaign == "spring"
+ end
+ end
+end