diff --git a/PROGRESS.md b/PROGRESS.md
index 232b00c..6c7e05a 100644
--- a/PROGRESS.md
+++ b/PROGRESS.md
@@ -29,7 +29,7 @@
Ordered by dependency level — admin shell chain first (unblocks most downstream work).
-Plans: [admin-redesign.md](docs/plans/admin-redesign.md) | [admin-font-loading.md](docs/plans/admin-font-loading.md) | [setup-wizard.md](docs/plans/setup-wizard.md) | [setup-and-launch.md](docs/plans/setup-and-launch.md) | [setup-auto-confirm.md](docs/plans/setup-auto-confirm.md) | [email-settings.md](docs/plans/email-settings.md) | [search.md](docs/plans/search.md) | [products-refactor.md](/home/jamey/.claude/plans/snug-roaming-zebra.md) | [shipping-sync.md](docs/plans/shipping-sync.md) | [printful-integration.md](docs/plans/printful-integration.md) | [provider-strategy.md](docs/plans/provider-strategy.md) | [css-migration.md](docs/plans/css-migration.md)
+Plans: [admin-redesign.md](docs/plans/admin-redesign.md) | [admin-font-loading.md](docs/plans/admin-font-loading.md) | [setup-wizard.md](docs/plans/setup-wizard.md) | [setup-and-launch.md](docs/plans/setup-and-launch.md) | [setup-auto-confirm.md](docs/plans/setup-auto-confirm.md) | [email-settings.md](docs/plans/email-settings.md) | [search.md](docs/plans/search.md) | [products-refactor.md](/home/jamey/.claude/plans/snug-roaming-zebra.md) | [shipping-sync.md](docs/plans/shipping-sync.md) | [printful-integration.md](docs/plans/printful-integration.md) | [provider-strategy.md](docs/plans/provider-strategy.md) | [css-migration.md](docs/plans/css-migration.md) | [analytics-v2.md](docs/plans/analytics-v2.md)
| # | Task | Depends on | Est | Status |
|---|------|------------|-----|--------|
diff --git a/docs/plans/analytics-v2.md b/docs/plans/analytics-v2.md
new file mode 100644
index 0000000..6a3ff92
--- /dev/null
+++ b/docs/plans/analytics-v2.md
@@ -0,0 +1,50 @@
+# Analytics dashboard v2
+
+Status: Planned
+
+## Current state (v1)
+
+- Unique visitors, pageviews, bounce rate, visit duration
+- SVG bar chart for visitor trends
+- Date range picker (Today, 7d, 30d, 12m)
+- Top pages, sources/referrers, countries, devices
+- E-commerce conversion funnel (product view → cart → checkout → purchase)
+- Data collection: UTMs, referrers, browser, OS, screen size, full e-commerce funnel
+
+## Improvements (priority order)
+
+### 1. Comparison mode
+
+Compare current period vs previous period. Show deltas on each metric (↑12%, ↓3%). Already have date ranges — just query the previous period in parallel and render the difference. Low effort, high value.
+
+**Files:** `lib/berrypod/analytics.ex` (queries), `lib/berrypod_web/live/admin/analytics.ex` (UI)
+
+### 2. Dashboard filtering
+
+Click any referrer, country, device, or page to filter the whole dashboard by that dimension. Turns a reporting page into an exploration tool. Single most useful feature for understanding traffic.
+
+**Approach:** Add filter params to the URL (e.g. `?country=GB&source=twitter.com`), thread them through all queries. Render active filters as dismissible chips.
+
+**Files:** analytics context (all query functions), analytics LiveView (params, UI)
+
+### 3. CSV export
+
+Export current dashboard view (respecting date range and filters) as CSV. Dead simple, always requested.
+
+**Approach:** Add a download button per panel (or one global export). Use a regular controller route that streams CSV.
+
+**Files:** new controller or plug for CSV response, analytics context for raw data queries
+
+### 4. Entry/exit pages
+
+Which pages people land on and leave from. Data likely already exists (first and last pageview per session). Just needs the query and a UI panel.
+
+**Files:** analytics context (new queries), analytics LiveView (new panel)
+
+## Future (lower priority)
+
+- **Real-time dashboard** — live visitor count, active pages (needs PubSub or polling)
+- **Line charts** — proper time-series with date axes (replace bar chart)
+- **UTM drill-downs** — campaign/medium/source breakdown views
+- **Goals/custom events** — user-defined conversion tracking
+- **Stats API** — JSON API for embedding elsewhere
diff --git a/lib/berrypod_web/live/admin/analytics.ex b/lib/berrypod_web/live/admin/analytics.ex
index 0ca7104..09fc5cf 100644
--- a/lib/berrypod_web/live/admin/analytics.ex
+++ b/lib/berrypod_web/live/admin/analytics.ex
@@ -361,20 +361,24 @@ defmodule BerrypodWeb.Admin.Analytics do
{"Purchase", assigns.funnel.purchases}
]
- max_val = steps |> Enum.map(&elem(&1, 1)) |> Enum.max(fn -> 1 end)
+ top_count = assigns.funnel.product_views
+
+ conversion_rate =
+ if top_count > 0,
+ do: Float.round(assigns.funnel.purchases / top_count * 100, 1),
+ else: 0.0
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
+ overall_rate = if top_count > 0, do: Float.round(count / top_count * 100, 1), else: 0.0
+ width_pct = if top_count > 0, do: max(count / top_count * 100, 5), else: 5
- %{label: label, count: count, rate: rate, width_pct: width_pct, index: i}
+ %{label: label, count: count, overall_rate: overall_rate, width_pct: width_pct, index: i}
end)
- assigns = assign(assigns, steps: steps_with_rates)
+ assigns = assign(assigns, steps: steps_with_rates, conversion_rate: conversion_rate)
~H"""
- {step.count}
+ {format_number(step.count)}
0}
- style="font-size: 0.75rem; color: color-mix(in oklch, var(--color-base-content) 60%, transparent);"
+ style="font-size: 0.8125rem; font-weight: 600;"
>
- {step.rate}%
+ {step.overall_rate}%
- 0} style="margin-top: 0.5rem; font-size: 0.875rem; font-weight: 600;">
- Revenue: {Cart.format_price(@revenue)}
+
+ {@conversion_rate}% overall conversion
+ 0}
+ style="color: color-mix(in oklch, var(--color-base-content) 60%, transparent);"
+ >
+ · Revenue: {Cart.format_price(@revenue)}
+
"""
diff --git a/priv/repo/seeds/analytics.exs b/priv/repo/seeds/analytics.exs
new file mode 100644
index 0000000..d04e0de
--- /dev/null
+++ b/priv/repo/seeds/analytics.exs
@@ -0,0 +1,398 @@
+# Generates realistic analytics demo data spanning 12 months.
+#
+# mix run priv/repo/seeds/analytics.exs
+#
+# Clears existing analytics events first, then creates ~50k events with
+# realistic traffic patterns, referrers, device mix, and e-commerce funnel.
+
+alias Berrypod.Repo
+alias Berrypod.Analytics.Event
+
+# ── Config ──
+
+# How many unique "visitors" to simulate per day (base — actual varies by day)
+base_daily_visitors = 40
+
+# Date range: 12 months back from today
+end_date = Date.utc_today()
+start_date = Date.add(end_date, -364)
+
+# ── Reference data ──
+
+# Shop pages and their relative popularity (weights)
+pages = [
+ {"/", 30},
+ {"/collections/t-shirts", 15},
+ {"/collections/hoodies", 12},
+ {"/collections/mugs", 10},
+ {"/collections/stickers", 8},
+ {"/products/1", 8},
+ {"/products/2", 7},
+ {"/products/3", 6},
+ {"/products/4", 5},
+ {"/products/5", 5},
+ {"/products/6", 4},
+ {"/products/7", 3},
+ {"/products/8", 3},
+ {"/products/9", 2},
+ {"/products/10", 2},
+ {"/about", 4},
+ {"/delivery", 3},
+ {"/contact", 2},
+ {"/privacy", 1},
+ {"/terms", 1},
+ {"/cart", 6}
+]
+
+# Referrer sources with weights (nil = direct traffic)
+sources = [
+ {nil, nil, 35},
+ {"google.com", "Google", 25},
+ {"instagram.com", "Instagram", 10},
+ {"facebook.com", "Facebook", 8},
+ {"tiktok.com", "TikTok", 6},
+ {"twitter.com", "Twitter", 4},
+ {"pinterest.com", "Pinterest", 4},
+ {"reddit.com", "Reddit", 3},
+ {"youtube.com", "YouTube", 2},
+ {"bing.com", "Bing", 1},
+ {"etsy.com", nil, 1},
+ {"blogpost.example.com", nil, 1}
+]
+
+# UTM campaigns (only applied to social/search traffic, not direct)
+campaigns = [
+ {nil, nil, nil, 60},
+ {"instagram", "social", "summer_sale", 10},
+ {"facebook", "cpc", "retarget_q4", 8},
+ {"google", "cpc", "brand_terms", 7},
+ {"tiktok", "social", "viral_hoodie", 5},
+ {"newsletter", "email", "weekly_digest", 5},
+ {"pinterest", "social", "pin_collection", 3},
+ {"twitter", "social", "launch_promo", 2}
+]
+
+# Countries with weights (UK-focused shop)
+countries = [
+ {"GB", 40},
+ {"US", 15},
+ {"DE", 7},
+ {"FR", 5},
+ {"CA", 4},
+ {"AU", 4},
+ {"NL", 3},
+ {"IE", 3},
+ {"SE", 2},
+ {"IT", 2},
+ {"ES", 2},
+ {"BE", 2},
+ {"NO", 1},
+ {"DK", 1},
+ {"PL", 1},
+ {"CH", 1},
+ {"NZ", 1},
+ {"JP", 1},
+ {"IN", 2},
+ {"BR", 2},
+ {"PT", 1}
+]
+
+# Browsers (matches UAParser output)
+browsers = [
+ {"Chrome", 55},
+ {"Safari", 25},
+ {"Firefox", 10},
+ {"Edge", 8},
+ {"Opera", 2}
+]
+
+# Operating systems (matches UAParser output)
+oses = [
+ {"iOS", 30},
+ {"Android", 25},
+ {"Windows", 22},
+ {"macOS", 15},
+ {"Linux", 5},
+ {"ChromeOS", 3}
+]
+
+# Screen sizes (matches classify_screen/1 in AnalyticsHook)
+screen_sizes = [
+ {"mobile", 55},
+ {"tablet", 12},
+ {"desktop", 33}
+]
+
+# ── Helpers ──
+
+# Weighted random pick from a list of {item, weight} or {a, b, weight} tuples
+weighted_pick = fn items ->
+ total = items |> Enum.map(&elem(&1, tuple_size(&1) - 1)) |> Enum.sum()
+ roll = :rand.uniform() * total
+
+ Enum.reduce_while(items, 0.0, fn item, acc ->
+ weight = elem(item, tuple_size(item) - 1)
+ acc = acc + weight
+ if acc >= roll, do: {:halt, item}, else: {:cont, acc}
+ end)
+end
+
+# Day-of-week multiplier (weekends get more traffic for a consumer shop)
+day_multiplier = fn date ->
+ case Date.day_of_week(date) do
+ 6 -> 1.3
+ 7 -> 1.2
+ 1 -> 0.85
+ _ -> 1.0
+ end
+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)
+end
+
+# Seasonal bumps (Nov-Dec holiday shopping, Jan sale)
+seasonal_multiplier = fn date ->
+ case date.month do
+ 11 -> 1.4
+ 12 -> 1.8
+ 1 -> 1.3
+ 6 -> 1.1
+ 7 -> 1.1
+ _ -> 1.0
+ end
+end
+
+# Random time of day (weighted toward daytime UK hours)
+random_time = fn ->
+ # Bell curve centered around 14:00 UTC (afternoon UK time)
+ hour = min(23, max(0, round(:rand.normal(14.0, 4.0))))
+ minute = :rand.uniform(60) - 1
+ second = :rand.uniform(60) - 1
+ Time.new!(hour, minute, second)
+end
+
+# ── Clear existing data ──
+
+IO.puts("Clearing existing analytics events...")
+{deleted, _} = Repo.delete_all(Event)
+IO.puts(" Deleted #{deleted} existing events")
+
+# ── Generate events ──
+
+IO.puts("Generating analytics data from #{start_date} to #{end_date}...")
+
+dates = Date.range(start_date, end_date)
+
+all_events =
+ Enum.flat_map(dates, fn date ->
+ # Calculate visitor count for this day
+ base = base_daily_visitors
+
+ multiplied =
+ base * day_multiplier.(date) * month_multiplier.(date) * seasonal_multiplier.(date)
+
+ # Add some randomness (+/- 20%)
+ jitter = 0.8 + :rand.uniform() * 0.4
+ visitor_count = round(multiplied * jitter)
+
+ # Generate visitors for this day
+ Enum.flat_map(1..visitor_count, fn _v ->
+ visitor_hash = :crypto.strong_rand_bytes(8)
+ session_hash = :crypto.strong_rand_bytes(8)
+
+ # Pick visitor attributes (consistent within a visit)
+ {referrer, referrer_source, _} = weighted_pick.(sources)
+ {country_code, _} = weighted_pick.(countries)
+ {browser, _} = weighted_pick.(browsers)
+ {os, _} = weighted_pick.(oses)
+ {screen_size, _} = weighted_pick.(screen_sizes)
+
+ # Maybe assign UTM params (only if has a referrer source)
+ {utm_source, utm_medium, utm_campaign} =
+ if referrer_source do
+ {src, med, camp, _} = weighted_pick.(campaigns)
+ {src, med, camp}
+ else
+ {nil, nil, nil}
+ end
+
+ # How many pages does this visitor view? (1-6, weighted toward fewer)
+ page_count = min(6, max(1, round(:rand.normal(2.0, 1.2))))
+
+ # Pick pages for this session
+ session_pages =
+ Enum.map(1..page_count, fn _ ->
+ {page, _} = weighted_pick.(pages)
+ page
+ end)
+
+ # Generate pageview events with increasing timestamps
+ base_time = random_time.()
+ base_dt = DateTime.new!(date, base_time, "Etc/UTC")
+
+ pageview_events =
+ session_pages
+ |> Enum.with_index()
+ |> Enum.map(fn {pathname, i} ->
+ # Each subsequent page is 15-120 seconds later
+ offset = if i == 0, do: 0, else: Enum.sum(for _ <- 1..i, do: 15 + :rand.uniform(105))
+ ts = DateTime.add(base_dt, offset, :second)
+
+ [
+ id: Ecto.UUID.generate(),
+ name: "pageview",
+ pathname: pathname,
+ visitor_hash: visitor_hash,
+ session_hash: session_hash,
+ referrer: referrer,
+ referrer_source: referrer_source,
+ utm_source: utm_source,
+ utm_medium: utm_medium,
+ utm_campaign: utm_campaign,
+ country_code: country_code,
+ browser: browser,
+ os: os,
+ screen_size: screen_size,
+ revenue: nil,
+ inserted_at: DateTime.truncate(ts, :second)
+ ]
+ end)
+
+ # E-commerce funnel events (progressive drop-off)
+ # Only trigger if visitor viewed a product page
+ viewed_product = Enum.any?(session_pages, &String.starts_with?(&1, "/products/"))
+
+ ecommerce_events =
+ if viewed_product do
+ product_path = Enum.find(session_pages, &String.starts_with?(&1, "/products/"))
+ last_ts = pageview_events |> List.last() |> Keyword.get(:inserted_at)
+
+ base_attrs = [
+ visitor_hash: visitor_hash,
+ session_hash: session_hash,
+ referrer: referrer,
+ referrer_source: referrer_source,
+ utm_source: utm_source,
+ utm_medium: utm_medium,
+ utm_campaign: utm_campaign,
+ country_code: country_code,
+ browser: browser,
+ os: os,
+ screen_size: screen_size
+ ]
+
+ # product_view: 100% of product page viewers
+ product_view = [
+ [
+ id: Ecto.UUID.generate(),
+ name: "product_view",
+ pathname: product_path,
+ revenue: nil,
+ inserted_at: DateTime.add(last_ts, 5, :second)
+ ] ++ base_attrs
+ ]
+
+ # add_to_cart: ~35% of product viewers
+ add_to_cart =
+ if :rand.uniform() < 0.35 do
+ [
+ [
+ id: Ecto.UUID.generate(),
+ name: "add_to_cart",
+ pathname: product_path,
+ revenue: nil,
+ inserted_at: DateTime.add(last_ts, 30, :second)
+ ] ++ base_attrs
+ ]
+ else
+ []
+ end
+
+ # checkout_start: ~60% of add-to-carters
+ checkout_start =
+ if add_to_cart != [] and :rand.uniform() < 0.60 do
+ [
+ [
+ id: Ecto.UUID.generate(),
+ name: "checkout_start",
+ pathname: "/cart",
+ revenue: nil,
+ inserted_at: DateTime.add(last_ts, 60, :second)
+ ] ++ base_attrs
+ ]
+ else
+ []
+ end
+
+ # purchase: ~70% of checkout starters
+ purchase =
+ if checkout_start != [] and :rand.uniform() < 0.70 do
+ # Revenue between 1500 (GBP 15.00) and 8500 (GBP 85.00)
+ revenue = 1500 + :rand.uniform(7000)
+
+ [
+ [
+ id: Ecto.UUID.generate(),
+ name: "purchase",
+ pathname: "/checkout/success",
+ revenue: revenue,
+ inserted_at: DateTime.add(last_ts, 120, :second)
+ ] ++ base_attrs
+ ]
+ else
+ []
+ end
+
+ product_view ++ add_to_cart ++ checkout_start ++ purchase
+ else
+ []
+ end
+
+ pageview_events ++ ecommerce_events
+ end)
+ end)
+
+# ── Batch insert ──
+
+total = length(all_events)
+IO.puts("Inserting #{total} events in batches...")
+
+all_events
+|> Enum.chunk_every(1000)
+|> Enum.with_index(1)
+|> Enum.each(fn {batch, i} ->
+ Repo.insert_all(Event, batch)
+ progress = min(i * 1000, total)
+ IO.write("\r #{progress}/#{total}")
+end)
+
+IO.puts("\n Done!")
+
+# ── Summary stats ──
+
+pageviews = Enum.count(all_events, fn e -> Keyword.get(e, :name) == "pageview" end)
+product_views = Enum.count(all_events, fn e -> Keyword.get(e, :name) == "product_view" end)
+add_to_carts = Enum.count(all_events, fn e -> Keyword.get(e, :name) == "add_to_cart" end)
+checkouts = Enum.count(all_events, fn e -> Keyword.get(e, :name) == "checkout_start" end)
+purchases = Enum.count(all_events, fn e -> Keyword.get(e, :name) == "purchase" end)
+
+total_revenue =
+ all_events
+ |> Enum.filter(fn e -> Keyword.get(e, :name) == "purchase" end)
+ |> Enum.map(fn e -> Keyword.get(e, :revenue) end)
+ |> Enum.sum()
+
+IO.puts("""
+
+Summary:
+ Pageviews: #{pageviews}
+ Product views: #{product_views}
+ Add to cart: #{add_to_carts}
+ Checkouts: #{checkouts}
+ Purchases: #{purchases}
+ Revenue: GBP #{Float.round(total_revenue / 100, 2)}
+""")